[Spring] 12. Singleton Bean과 상태값 주의사항

[Spring] 12. Singleton Bean과 상태값 주의사항

이전 글에서는 Bean Scope에 대해 살펴봤습니다. Bean Scope는 Spring Bean이 생성되고 유지되는 범위를 의미합니다. Spring Bean은 기본적으로 Singleton Scope로 관리됩니다.

이번 글에서는 Singleton Bean을 조금 더 자세히 정리하겠습니다. 특히 Singleton Bean에서 가장 조심해야 하는 상태값 문제를 예제로 살펴보겠습니다.

핵심은 간단합니다. Singleton Bean은 하나의 객체를 여러 곳에서 함께 사용하므로, 사용자별로 달라지는 값을 필드에 저장하면 위험합니다.


1. Singleton Bean이란?

Singleton Bean은 Spring Container 안에서 하나만 생성되어 여러 곳에서 공유되는 Bean입니다.

Spring Container
 └─ boardService Bean 1개

BoardController → boardService 사용
AdminController → boardService 사용
ApiController   → boardService 사용

위 구조에서 BoardService Bean은 하나만 생성됩니다. 그리고 여러 Controller가 같은 BoardService Bean을 주입받아 사용합니다.

@Service
public class BoardService {
}

위처럼 별도의 Scope 설정 없이 @Service로 등록하면 기본적으로 Singleton Bean으로 관리됩니다. Controller, Service, Repository, Mapper 같은 대부분의 Bean은 Singleton으로 사용됩니다.


2. Singleton Bean을 사용하는 이유

웹 애플리케이션에서는 요청이 계속 들어옵니다. 게시글 목록 조회, 로그인, 댓글 등록, 파일 업로드 같은 요청이 여러 사용자에게서 동시에 발생할 수 있습니다.

그때마다 Controller, Service, Repository 객체를 계속 새로 만든다면 비효율적입니다. 대부분의 Service나 Repository는 사용자별 데이터를 직접 저장하는 객체가 아니라, 기능을 수행하는 객체입니다.

요청 1 → BoardService 사용
요청 2 → BoardService 사용
요청 3 → BoardService 사용

BoardService 객체는 하나만 생성해서 공유

Spring은 이런 객체들을 하나만 만들어 공유함으로써 객체 생성 비용을 줄이고, 애플리케이션 구조를 효율적으로 관리합니다. 그래서 Spring Bean의 기본 Scope는 Singleton입니다.


3. Singleton Bean의 핵심 특징

객체 개수 Spring Container 안에서 하나만 생성됩니다.
공유 여부 여러 클래스와 여러 요청이 같은 객체를 공유합니다.
기본 Scope Spring Bean의 기본 Scope입니다.
주의점 사용자별로 달라지는 상태값을 필드에 저장하면 안 됩니다.

여기서 가장 중요한 것은 공유입니다. Singleton Bean은 여러 요청이 함께 사용하는 객체입니다. 따라서 Bean 내부 필드에 값을 저장할 때는 매우 조심해야 합니다.


4. 상태값이란?

상태값은 객체가 내부에 저장하고 있는 값을 의미합니다. 예를 들어 클래스의 필드에 저장되는 값이 상태값이 될 수 있습니다.

@Service
public class BoardService {

    private String title;
}

위 코드에서 title은 BoardService 객체가 가지고 있는 필드입니다. 만약 이 필드에 사용자별 게시글 제목을 저장한다면 문제가 생길 수 있습니다.

왜냐하면 BoardService는 Singleton Bean일 가능성이 높고, 여러 사용자가 같은 BoardService 객체를 함께 사용하기 때문입니다.


5. 위험한 코드 예시

다음 코드는 Singleton Bean에서 조심해야 하는 예시입니다.

@Service
public class BoardService {

    private String title;

    public void write(String title) {
        this.title = title;
    }

    public String getTitle() {
        return title;
    }
}

이 코드는 게시글 제목을 Service의 필드에 저장하고 있습니다. 겉으로 보면 단순해 보이지만, 여러 사용자가 동시에 요청하면 위험합니다.

사용자 A → write("A의 게시글")
사용자 B → write("B의 게시글")

같은 BoardService 객체의 title 필드를 함께 사용

사용자 A가 저장한 title 값이 사용자 B의 요청으로 바뀔 수 있습니다. 그러면 사용자 A가 기대한 값과 다른 값이 반환될 수 있습니다.

즉, Singleton Bean에 사용자별 데이터를 필드로 저장하면 데이터가 섞일 수 있습니다.


6. 장바구니 예시로 이해하기

장바구니 기능을 예로 들어보겠습니다. 다음 코드는 매우 위험한 구조입니다.

@Service
public class CartService {

    private Long memberId;
    private List<Long> productIds = new ArrayList<>();

    public void addCart(Long memberId, Long productId) {
        this.memberId = memberId;
        this.productIds.add(productId);
    }

    public List<Long> getProductIds() {
        return productIds;
    }
}

CartService는 기본적으로 Singleton Bean입니다. 즉, 모든 사용자가 같은 CartService 객체를 사용합니다.

사용자 A → 상품 1번 장바구니 추가
사용자 B → 상품 7번 장바구니 추가
사용자 C → 상품 3번 장바구니 추가

같은 CartService의 productIds에 모두 저장될 수 있음

이렇게 되면 사용자별 장바구니가 분리되지 않습니다. 여러 사용자의 장바구니 데이터가 하나의 리스트에 섞일 수 있습니다.

장바구니 데이터는 Service 필드에 저장하면 안 됩니다. 보통 DB, Session, Redis, 요청 DTO 등 목적에 맞는 저장 위치를 사용해야 합니다.


7. 안전한 코드 구조

Singleton Bean에서는 사용자별로 달라지는 값을 필드에 저장하지 말고, 메서드 파라미터나 지역 변수로 처리하는 것이 좋습니다.

@Service
public class BoardService {

    public void write(String title) {
        System.out.println("게시글 제목: " + title);
    }
}

위 코드는 title을 필드에 저장하지 않습니다. 요청으로 받은 값을 메서드 안에서만 사용합니다.

장바구니 기능도 다음처럼 Service가 직접 상태를 들고 있는 것이 아니라, 필요한 값을 받아서 DB나 다른 저장소에 처리하도록 구성하는 것이 좋습니다.

@Service
public class CartService {

    private final CartMapper cartMapper;

    public CartService(CartMapper cartMapper) {
        this.cartMapper = cartMapper;
    }

    public void addCart(Long memberId, Long productId) {
        cartMapper.insertCart(memberId, productId);
    }
}

이 구조에서 CartService는 memberId나 productIds를 필드에 저장하지 않습니다. 요청마다 전달받은 값을 DB 처리에 사용하고 끝냅니다.

좋은 구조
 → 사용자별 데이터는 파라미터로 받기
 → 필요한 처리는 메서드 안에서 수행
 → 저장이 필요하면 DB, Session, Redis 등에 저장
 → Service 필드에는 사용자별 상태값 저장하지 않기

8. 상태가 없는 객체란?

Singleton Bean은 보통 상태가 없는 객체로 만드는 것이 좋습니다. 상태가 없다는 말은 사용자별로 달라지는 값을 필드에 저장하지 않는다는 의미입니다.

상태가 있는 객체 필드에 사용자별 데이터나 요청별 데이터를 저장합니다.
상태가 없는 객체 필드에 사용자별 데이터를 저장하지 않고, 파라미터와 지역 변수로 처리합니다.

Spring의 Controller, Service, Repository는 대부분 상태가 없는 구조로 작성합니다.

@Service
public class MemberService {

    private final MemberMapper memberMapper;

    public MemberService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    public MemberDto findMember(Long memberId) {
        return memberMapper.findById(memberId);
    }
}

위 코드에서 MemberService는 memberId를 필드에 저장하지 않습니다. 메서드 파라미터로 받아서 Mapper에 전달합니다. 이런 구조가 Singleton Bean에서 안전한 구조입니다.


9. 필드가 전부 위험한 것은 아니다

그렇다고 Singleton Bean에 필드를 절대 쓰면 안 된다는 뜻은 아닙니다. 중요한 것은 어떤 값을 필드로 두느냐입니다.

다음과 같은 의존성 필드는 괜찮습니다.

@Service
public class BoardService {

    private final BoardMapper boardMapper;

    public BoardService(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }
}

여기서 boardMapper는 사용자별로 바뀌는 값이 아닙니다. BoardService가 DB 작업을 하기 위해 사용하는 의존 객체입니다. 이런 필드는 Singleton Bean에서 일반적으로 사용합니다.

괜찮은 필드 Mapper, Repository, 다른 Service, 설정값처럼 공유되어도 문제없는 의존성
위험한 필드 memberId, title, cartList처럼 사용자별 또는 요청별로 달라지는 값

즉, 필드 자체가 문제라기보다 공유되면 안 되는 값을 필드에 저장하는 것이 문제입니다.


10. 의존성 필드와 상태값 필드 차이

처음에는 의존성 필드와 상태값 필드가 헷갈릴 수 있습니다. 아래처럼 구분하면 됩니다.

구분 의존성 필드 상태값 필드
예시 BoardMapper, MemberRepository, MailSender memberId, title, cartList
역할 기능 수행을 위해 필요한 객체 요청이나 사용자에 따라 달라지는 값
공유 가능 여부 보통 공유해도 괜찮음 공유되면 위험함
사용 방식 생성자 주입으로 받음 파라미터, 지역 변수, DTO, DB, Session 등으로 처리

Service 클래스의 필드에는 보통 의존성 객체를 둡니다. 사용자별 데이터는 필드가 아니라 메서드 호출 과정에서 전달받아 처리하는 것이 안전합니다.


11. 멀티스레드 환경에서 더 위험하다

웹 애플리케이션은 여러 사용자의 요청을 동시에 처리할 수 있습니다. 즉, 같은 Singleton Bean을 여러 요청이 동시에 사용할 수 있습니다.

요청 A ─┐
        ├─ 같은 BoardService Bean 사용
요청 B ─┘

이런 환경에서 Singleton Bean의 필드에 요청 데이터를 저장하면 서로 다른 요청이 같은 값을 덮어쓸 수 있습니다.

요청 A: title = "A 게시글"
요청 B: title = "B 게시글"

요청 A가 다시 title을 읽으면
이미 "B 게시글"로 바뀌어 있을 수 있음

그래서 Singleton Bean은 상태를 가지지 않도록 작성하는 것이 중요합니다. 이것을 무상태, 즉 Stateless하게 작성한다고 말합니다.


12. Stateless Service 구조

Stateless Service는 사용자별 상태를 필드에 저장하지 않는 Service입니다.

@Service
public class OrderService {

    private final OrderMapper orderMapper;
    private final PointService pointService;

    public OrderService(OrderMapper orderMapper, PointService pointService) {
        this.orderMapper = orderMapper;
        this.pointService = pointService;
    }

    public void order(Long memberId, Long productId) {
        orderMapper.insertOrder(memberId, productId);
        pointService.savePoint(memberId);
    }
}

위 코드에서 OrderService는 memberId와 productId를 필드에 저장하지 않습니다. 메서드 파라미터로 받아서 주문 저장과 포인트 적립에 사용합니다.

이런 구조가 Singleton Bean에서 안전합니다. Service는 기능의 흐름을 처리하고, 사용자별 데이터 저장은 DB나 Session 같은 적절한 저장 위치가 맡습니다.


13. 자주 하는 실수

1) 로그인 사용자 정보를 Service 필드에 저장

@Service
public class LoginService {

    private Long loginMemberId;

    public void login(Long memberId) {
        this.loginMemberId = memberId;
    }
}

이 구조는 위험합니다. 여러 사용자가 로그인하면 같은 loginMemberId 필드가 계속 바뀔 수 있습니다. 로그인 사용자 정보는 보통 Session이나 Spring Security의 인증 정보에서 관리합니다.

2) 요청 DTO를 Service 필드에 저장

@Service
public class BoardService {

    private BoardDto boardDto;

    public void write(BoardDto boardDto) {
        this.boardDto = boardDto;
    }
}

DTO는 요청마다 달라지는 데이터입니다. 이런 값을 Singleton Service의 필드에 저장하면 요청 데이터가 섞일 수 있습니다. DTO는 메서드 파라미터로 받아 처리하는 것이 좋습니다.

3) List나 Map을 필드로 두고 사용자 데이터를 저장

@Service
public class RecentViewService {

    private Map<Long, List<Long>> recentViews = new HashMap<>();

    public void addRecentView(Long memberId, Long contentId) {
        recentViews
            .computeIfAbsent(memberId, key -> new ArrayList<>())
            .add(contentId);
    }
}

이런 구조는 메모리 관리, 동시성, 서버 재시작 시 데이터 유실 같은 문제가 생길 수 있습니다. 최근 본 콘텐츠 같은 데이터는 DB나 Redis 같은 저장소를 사용하는 것이 더 적절할 수 있습니다.

4) Singleton이니까 무조건 위험하다고 생각하는 경우

Singleton 자체가 위험한 것은 아닙니다. Spring의 기본 Bean Scope가 Singleton인 이유는 대부분의 Controller, Service, Repository가 상태를 저장하지 않는 구조로 작성되기 때문입니다.

문제는 Singleton이 아니라
Singleton Bean에 공유되면 안 되는 값을 저장하는 것이다.

14. 안전한 Singleton Bean 작성 규칙

규칙 설명
사용자별 데이터는 필드에 저장하지 않기 memberId, title, cartList 같은 값은 요청마다 달라질 수 있습니다.
의존성은 final 필드로 주입받기 Mapper, Repository, 다른 Service는 생성자 주입으로 받는 것이 좋습니다.
요청 데이터는 파라미터로 받기 Controller에서 받은 DTO나 ID 값은 Service 메서드 파라미터로 전달합니다.
저장이 필요하면 적절한 저장소 사용하기 DB, Session, Redis 등 데이터 성격에 맞는 저장 위치를 사용합니다.
Service는 기능 흐름에 집중하기 Service는 데이터를 들고 있기보다 기능의 순서를 처리하는 계층입니다.

이 규칙만 지켜도 Singleton Bean으로 인한 많은 문제를 피할 수 있습니다.


15. 게시판 프로젝트 기준 예시

게시판 등록 기능을 기준으로 보면 좋은 구조는 다음과 같습니다.

@Controller
public class BoardController {

    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @PostMapping("/board/write")
    public String write(BoardDto boardDto, HttpSession session) {
        Long memberId = (Long) session.getAttribute("loginMemberId");
        boardService.write(memberId, boardDto);
        return "redirect:/board/list";
    }
}
@Service
public class BoardService {

    private final BoardMapper boardMapper;

    public BoardService(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    public void write(Long memberId, BoardDto boardDto) {
        boardDto.setMemberId(memberId);
        boardMapper.insertBoard(boardDto);
    }
}

이 구조에서 BoardService는 로그인 회원 번호나 게시글 DTO를 필드에 저장하지 않습니다. Controller에서 받은 값을 Service 메서드로 전달하고, Service는 필요한 처리를 수행한 뒤 Mapper에 전달합니다.

Controller
 → 요청 데이터 받기

Service
 → 기능 흐름 처리

Mapper
 → DB 저장

이렇게 하면 Singleton Bean을 안전하게 사용할 수 있습니다.


정리

Singleton Bean은 Spring Container 안에서 하나만 생성되어 여러 곳에서 공유되는 Bean입니다. Spring Bean의 기본 Scope는 Singleton이며, Controller, Service, Repository는 대부분 Singleton으로 사용됩니다.

Singleton Bean에서 가장 중요한 주의사항은 상태값입니다. 사용자별로 달라지는 값을 필드에 저장하면 여러 요청 사이에서 데이터가 섞일 수 있습니다.

괜찮은 필드 Mapper, Repository, 다른 Service 같은 의존성 객체
위험한 필드 memberId, title, cartList, 요청 DTO 같은 사용자별 데이터

따라서 Singleton Bean은 상태를 가지지 않는 구조로 작성하는 것이 좋습니다. 요청마다 달라지는 값은 파라미터, 지역 변수, DTO, DB, Session 등을 통해 처리해야 합니다.

Singleton Bean 안전하게 쓰는 핵심

1. 사용자별 데이터는 필드에 저장하지 않는다.
2. 의존성 객체는 final 필드로 주입받는다.
3. 요청 데이터는 메서드 파라미터로 전달한다.
4. 저장이 필요하면 DB, Session, Redis 등을 사용한다.
5. Service는 상태 저장보다 기능 흐름 처리에 집중한다.

연습해보기

아래 코드에서 위험한 부분을 찾아보세요.

@Service
public class ReviewService {

    private Long memberId;
    private String content;
    private int score;

    public void writeReview(Long memberId, String content, int score) {
        this.memberId = memberId;
        this.content = content;
        this.score = score;
    }
}

ReviewService는 Singleton Bean일 가능성이 높습니다. 따라서 memberId, content, score처럼 요청마다 달라지는 값을 필드에 저장하면 위험합니다. 이 값들은 메서드 파라미터나 ReviewDto로 전달받아 처리하고, 저장이 필요하면 DB에 저장하는 것이 좋습니다.

다음 글에서는 @Configuration@Bean에 대해 정리하겠습니다. 직접 만든 클래스가 아니라 외부 라이브러리 객체를 Spring Bean으로 등록하는 방법을 알아보겠습니다.

'Tech Stack > Spring Boot' 카테고리의 다른 글

[Spring] 14. Bean 생명주기  (0) 2026.07.08
[Spring] 13. @Configuration과 @Bean  (0) 2026.07.07
[Spring] 11. Bean Scope란?  (0) 2026.07.05
[Spring] 10. @Qualifier와 @Primary  (0) 2026.07.04
[Spring] 09. @Autowired와 생성자 주입  (0) 2026.07.03