의문의 시작
디자인 패턴을 공부하면서 의문이 들었다.
https://cutewonny.tistory.com/entry/%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4
singleton.. 객체를 재사용한다는 것인데...
초기화가 제대로 되지 않으면, 재사용할때 문제가 생기는거 아닐까?
근데 왜 싱글톤을 쓰는거지? 위험하게? 어떻게 해야 초기화를 제대로 할 수 있을까?
그래도 객체를 미리 만들어 놓고 사용하는건
꽤나 많은 곳에서 쓰이고 있으니 장점이 있다는 건데..
singleton pattern에 대한 의문, 활용 사례
sigleton pattern을 사용한다는 것은 객체를 한 번만 생성하고, 이를 재사용한다는 뜻입니다.
회사에서나 프로젝트에서 흔히 사용되는 패턴이지만, 이를 공부하면서 몇 가지 의문이 들었습니다.
singleton pattern의 문제점: 초기화와 재사용
singleton은 객체를 재사용하기 때문에, 초기화가 제대로 이루어지지 않으면 재사용 시 문제가 생길 가능성이 있습니다.
예를 들어, 초기 상태에서 필드 값이 잘못 설정되거나, 초기화가 필요한 자원이 누락된 경우 문제가 발생할 수 있습니다.
왜? singleton을 사용하는 걸까?
singleton을 사용하는 가장 큰 이유는 자원의 효율적인 관리와 성능 최적화입니다. 하지만 "왜 굳이 위험하게 singleton을 써야 할까?"라는 질문은 타당한 고민입니다. 다음은 singleton을 사용하는 이유와 이를 안전하게 초기화하는 방법에 대해 정리한 내용입니다.
singleton pattern
- class 의 instance가 딱 1개만 생성하는 것을 보장하는 design pattern
- 객체 instance를 2개 이상 생성하지 못하도록 막는다
- private 생성자 -> 외부에서 new 사용하지 못하도록 막는다
singleton pattern 장점
- 자원 절약
객체를 반복적으로 생성하지 않고, 한 번만 생성해서 재사용하므로 메모리와 자원을 절약 - 공유된 상태 관리
애플리케이션 내에서 공통된 데이터를 관리하거나 설정을 공유할 때 유리 - 전역 접근성
어디서나 동일한 객체에 접근 가능하며, 이를 통해 통합된 상태를 관리 가능
singleton pattern 문제점
- 구현하는 코드 자체가 많이 들어간다
- 의존관계상 client가 concreate class에 의존한다 -> DIP 위반
- client가 concreate class에 의존한다 -> OCP 위반
- 테스트 하기 어렵다
- 내부 속성을 변경하거나 초기화 하기 어렵다
- private 생성자 -> 자식 class를 만들기 어렵다
- 동시성 문제 발생
결론:
- 유연성이 떨어진다.
- anti-pattern이라고 불리기도 한다.
동시성 문제(multi thread)
singleton으로 등록된 spring bean은 instance가 하나이다.
여러 쓰레드가 동시에 접근할때 발생한다.
singleton class 내부에 field가 있으면, 공유자원이 되어서, field에 여러 thread가 접근하기 때문에 문제가 발생한다.
![]() |
![]() |
![]() |
동시성 발생 코드
package hello.advanced.trace.threadlocal.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class FieldService {
private String nameStore;
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore);
nameStore = name;
sleep(1000);
log.info("조회 nameStore={}", nameStore);
return nameStore;
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
package hello.advanced.trace.threadlocal;
import hello.advanced.trace.threadlocal.code.FieldService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class FieldServiceTest {
private FieldService fieldService = new FieldService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> {
fieldService.logic("userA");
};
Runnable userB = () -> {
fieldService.logic("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start(); //A실행
sleep(2000); //동시성 문제 발생X
// sleep(100); //동시성 문제 발생O
threadB.start(); //B실행
sleep(3000); //메인 쓰레드 종료 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
동시성 문제는 지역 변수에서는 발생하지 않는다
지역 변수는 thread 마다 각각 다른 memory 영역이 할당되기 때문이다.
동시성 문제가 발생하는 곳
- instance field(singleton에서 자주 발생)
- static 공용 field 에 접근할때 발생한다
읽기만 하면 동시성 문제는 발생하지 않는다. 어디선가 값을 변경하면 동시성 문제가 발생한다.
동시성 문제 해결 방법?
thread local 사용
thread local은 해당 thread만 접근할 수 있는 특별한 저장소
![]() |
![]() |
![]() |
ThreadLocal 적용
package hello.advanced.trace.logtrace;
import hello.advanced.trace.TraceId;
import hello.advanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ThreadLocalLogTrace implements LogTrace {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>(); // ThreadLocal 적용
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder.get();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX,
traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
@Override
public void end(TraceStatus status) {
complete(status, null);
}
@Override
public void exception(TraceStatus status, Exception e) {
complete(status, e);
}
private void complete(TraceStatus status, Exception e) {
Long stopTimeMs = System.currentTimeMillis();
long resultTimeMs = stopTimeMs - status.getStartTimeMs();
TraceId traceId = status.getTraceId();
if (e == null) {
log.info("[{}] {}{} time={}ms", traceId.getId(),
addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(),
resultTimeMs);
} else {
log.info("[{}] {}{} time={}ms ex={}", traceId.getId(),
addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs,
e.toString());
}
releaseTraceId();
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId == null) {
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove();//destroy!!!!!!!!!!! 반드시 제거
} else {
traceIdHolder.set(traceId.createPreviousId());
}
}
private static String addSpace(String prefix, int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append((i == level - 1) ? "|" + prefix : "| ");
}
return sb.toString();
}
}
package hello.advanced.trace.logtrace;
import hello.advanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
class ThreadLocalLogTraceTest {
ThreadLocalLogTrace trace = new ThreadLocalLogTrace();
@Test
void begin_end_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
trace.end(status2);
trace.end(status1);
}
@Test
void begin_exception_level2() {
TraceStatus status1 = trace.begin("hello");
TraceStatus status2 = trace.begin("hello2");
trace.exception(status2, new IllegalStateException());
trace.exception(status1, new IllegalStateException());
}
}
singleton 활용 사례
Spring container(@Configuration)
- spring container는 singleton pattern의 문제점을 해결하면서, 객체의 instance를 싱글톤으로 관리한다
- @Configuration 이 CGLIB가 붙은 상속받은 객체를 만들어서, singleton pattern을 적용하여 bean을 관리한다
![]() |
![]() |
왼쪽처럼 요청마다 객체를 생성하게되면
- 고객 트래픽이 초당 100이면 -> 1초당 100개의 객체가 생성되고 소멸된다 -> 메모리 낭비 심하다
- 해결 방안: 객체가 1개 생기고, 공유하도록 설계 -> singleton pattern
spring container를 만드는 곳은 AppConfig.class 이다.
AppConfig에는 @Configuration, @Bean Annotation을 달아주면, spring이 singleton으로 객체를 관리한다.
따라서, 객체를 여러번 new하지 않고, DI(dependency injection)으로 넣어서 재사용하게 만든다.
JPA의 EntityManagerFactory
- JPA 표준 인터페이스로, EntityManager 객체를 생성하고 관리하는 팩토리 클래스
- EntityManagerFactory는 EntityManager의 생성, 초기화, 소멸을 관리합니다.
- EntityManagerFactory는 Thread-safe하며, 여러 쓰레드에서 동시에 사용할 수 있습니다.
- 반면 EntityManager는 쓰레드에 안전하지 않으므로, 요청마다 별도로 생성해야 합니다.
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
public class JpaExample {
public static void main(String[] args) {
// EntityManagerFactory 생성
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");
// EntityManager 생성
EntityManager em = emf.createEntityManager();
// 트랜잭션 작업
em.getTransaction().begin();
// 엔터티 저장
MyEntity entity = new MyEntity();
entity.setName("Example");
em.persist(entity);
em.getTransaction().commit();
// EntityManager 닫기
em.close();
// 애플리케이션 종료 시 EntityManagerFactory 닫기
emf.close();
}
}
Jpa persist context
Jpa persist context는 singleton이 아니라, cache를 사용한다
- 동일 transaction 내에서는 1차 캐시에 저장된 entity를 재사용
- EntityManager가 관리하는 영속 상태의 엔티티를 메모리에 저장하여, 같은 transaction 내에서 동일 ID의 entity를 여러 번 요청할 때, 데이터베이스를 다시 조회하지 않고 캐싱된 entity 반환
EntityManager마다 별도의 영속성 컨텍스트
- EntityManager는 요청이나 트랜잭션 범위마다 새로 생성되며, 각각 독립적인 영속성 컨텍스트를 가집니다.
- 따라서 영속성 컨텍스트는 싱글톤과 달리 전역적으로 하나만 존재하지 않습니다.
1차 캐시의 범위
- 영속성 컨텍스트는 트랜잭션 단위로 엔티티를 관리합니다.
트랜잭션이 끝나거나 EntityManager가 닫히면 캐시도 사라집니다. - 싱글톤은 애플리케이션 전체 수명 동안 하나의 인스턴스만 유지됩니다.
다중 EntityManager 지원
- 애플리케이션에서 여러 EntityManager를 생성하면, 각각 고유한 영속성 컨텍스트를 가집니다.
- 이는 싱글톤과 달리 멀티 인스턴스를 허용하는 동작입니다.
singleton 이랑 비슷해 보이는 것들
WAS - thread pool(resource pool pattern)
- 리소스 관리 전략 설계
- 요청마다 thread 생성 시
- 장점
- 동시 요청을 처리 가능
- resource(CPU, memory)가 허용할때까지 처리 가능
- 하나의 thread가 지연되어도, 나머지 thread는 정상 동작
- 단점
- thread 생성 비용은 매우 비싸다: 고객이 요청이 올때마다, thread를 생성하면, 응답 속도가 늦어진다
- context switching 비용 발생
- thread 생성에 제한 없음 -> 임계점이 넘으면 서버가 죽는다
- 장점
- resource pool pattern
- 필요한 thread를 thread 풀에 보관, 관리
- thread pool에 생성 가능한 thread 최대치를 관리(tomcat default 200)
- 장점
- thread가 미리 생성되어 있음. thread 생성, 종료 비용이 절약됨, 응답 빨라짐
- 최대치 thread가 미리 생성되어있어서, 많은 요청이 와도 기존 request를 안전하게 처리
Servlet Container
- servlet 객체는 기본적으로 singleton으로 관리된다
- servlet container는 초기화때 servlet을 생성하고, 모든 요청을 동일한 객체를 사용한다
![]() |
![]() |
front controller pattern
- front controller servlet 하나로 client들의 요청을 받음
- front controller가 request에 맞는 controller를 찾아서 호출
- 입구가 하나!
- 공통 처리 가능
- front controller를 제외한 나머지 controller는 servlet을 사용하지 않아도 됨
- servlet container 적용된 pattern
- Front Controller pattern: 요청을 중앙에서 처리하고 분배
- Factory pattern: servlet, filter, Listener 객체 생성 및 초기화
- Singleton pattern: servlet instance 관리
- Template Method pattern: servlet life cycle 관리
- Observer pattern: 이벤트 기반 동작
- Decorator pattern: request, response에 계층적으로 기능 추가 ex) filter
- Strategy pattern: url mapping
singleton을 안전하게 초기화하려면?
Stateless (무상태) 설계
여러 client가 하나의 같은 객체 instance를 공유한다.
따라서 singleton 객체는 상태를 유지하게 설계하면 안된다.(Stateful)
- 특정 client에 의존적인 필드가 있으면 안된다
- 특정 client가 값을 변경할 수 있는 필수가 있으면 안된다.
- 가급적 읽기만 가능해야 한다
- field 대신에 java에서 공유되지 않는, 지역 변수, parameter, ThreadLocal 등을 사용해야한다
- spring bean의 field에 공유 값을 설정하면 정말 큰 장애가 발생할 수 있다.
stateful error example
package hello.core.singleton;
public class StatefulService {
private int price; //상태를 유지하는 필드
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price; //여기가 문제!
}
public int getPrice() {
return price;
}
}
package hello.core.singleton;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
public class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new
AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);
//ThreadA: A사용자 10000원 주문
statefulService1.order("userA", 10000);
//ThreadB: B사용자 20000원 주문
statefulService2.order("userB", 20000);
//ThreadA: 사용자A 주문 금액 조회
int price = statefulService1.getPrice();
//ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
`StatefulService` 의 `price` 필드는 공유되는 필드인데, 특정 클라이언트가 값을 변경한다.
사용자A의 주문금액은 10000원이 되어야 하는데, 20000원이라는 결과가 나왔다.(error 발생)
Thread Local 사용
private ThreadLocal<타입> 변수명 = new ThreadLocal<>();
Lazy Initialization (지연 초기화)
객체가 필요할 때 생성하도록 설계하면, 초기화 과정에서의 불필요한 리소스 낭비를 줄일 수 있습니다.
public class Singleton {
private static Singleton instance;
private Singleton() {
// 초기화 로직
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
Thread-safe Initialization (쓰레드 안전)
멀티쓰레드 환경에서도 안전하도록 synchronized, volatile 키워드를 활용합니다.
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// 초기화 로직
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
'기술 블로그 (Tech Blog) > Dev Notes' 카테고리의 다른 글
자료구조 정리 (0) | 2024.12.15 |
---|---|
4대천왕👑이 정리한 객체 정의서(Gof 디자인패턴) (0) | 2024.12.07 |
사가패턴은 왜 사가라고 하는걸까? 🍎아님 (0) | 2024.11.16 |