[Springboot]대용량 트래픽, 싱글톤패턴, @Transactional, JPA N+1문제

Michelle Kim·2025년 5월 25일

Springboot-Java

목록 보기
20/20

Springboot

🟧 대용량 트래픽에서 장애가 발생하면 어떻게 대응할 것인가?

스케일 업을 통해 하드웨어 스펙을 향상 / 스케일 아웃을 통해 서버를 여러대 추가해 시스템을 증가시키면된다.

🟡 스케일 업, 스케일 아웃이란?

👉 스케일 업(Scale-Up)

스케일 업은 기존 서버의 사양을 업그레이드해 시스템을 확장하는 것
CPU나 RAM 등을 추가하거나 고성능의 부품, 서버로 교환하는 방법

👉 스케일 아웃(Scale-Out)

스케일 아웃은 서버를 여러 대 추가하여 시스템을 확장하는 것
서버가 여러 대로 나뉘기 때문에 각 서버에 걸리는 부하를 균등하게 해주는 '로드밸런싱'이 필수적으로 동반되어야 한다.

🟧 Spring의 싱글톤 패턴에 대해 설명

🟡 싱글톤 패턴 이란?

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다.
  • 그래서 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다.
    - private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다.
package hello.core.singleton;

public class SingletonService {

    // 1. static 영역에 객체를 딱 1개만 생성해둔다.
    // 자기 자신을 내부에 private로 가지는데, static으로 가짐
    private static final SingletonService instance = new SingletonService();

    // public으로 열어서 객체 인스턴스가 필요하면 이 static 메소드를 통해 조회하도록 허용
    public static SingletonService getInstance() {
        return instance;
    }

    // 생성자를 private으로 선언해 외부에서 new 키워드를 사용한 객체 생성을 못하도록 막음
    private SingletonService() {
    }

    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

1) static 영역에 객체 instance를 미리 하나 생성해서 올려둔다.
2) 이 객체 인스턴스가 필요하면 오직 getInstance() 메서드를 통해서만 조회할 수 있다. 이 메서드를 호출하면 항상 같은 인스턴스를 반환한다.
3) 딱 1개의 객체 인스턴스만 존재해야 하므로, 생성자를 private으로 막아서 혹시라도 외부에서 new 키워드로 객체 인스턴스가 생성되는 것을 막는다.

그러나 싱글톤 패턴도 몇 가지 문제점을 가진다
1. 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
2. 의존관계상 클라이언트가 구체 클래스에 의존한다. DIP를 위반한다.
3. 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
4. 테스트하기 어렵다.
5. 내부 속성을 변경하거나 초기화 하기 어렵다.
6. private 생성자로 자식 클래스를 만들기 어렵다.
7. 결론적으로 유연성이 떨어진다.
8. 안티패턴으로 불리기도 한다.

🟡 스프링 컨테이너를 쓰면, 스프링 컨테이너가 기본적으로 객체를 다 싱글톤으로 만들어서 관리해준다!

스프링에서 bean 생성시 별다른 설정이 없으면 default로 싱글톤이 적용된다.
스프링은 컨테이너를 통해 직접 싱글톤 객체를 생성하고 관리하는데, 다음과 같은 장점을 얻을 수 있다.

  • static 메소드나 private 생성자 등을 사용하지 않아 객체지향적 개발을 할 수 있다.
  • 테스트를 하기 편리하다.

🟡 싱글톤 방식의 주의점

싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다

무상태(stateless)로 설계해야 한다

  • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다!
  • 가급적 읽기만 가능해야 한다.(가급적 값을 수정하면 안 된다)
  • 필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

@Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지는 않음!
(싱글톤을 보장하기 위해서는 @Configuration이 필요)

🟧 @Transactional의 동작 원리에 대해 설명

+++추가 필요!!

🟡 @Transactional에 readOnly 속성을 사용하는 이유에 대해서 설명

트랜잭션 안에서 수정/삭제 작업이 아닌 ReadOnly 목적인 경우에 주로 사용하며,
영속성 컨텍스트에서 엔티티를 관리 할 필요가 없기 때문에 readOnly를 추가하는 것으로 메모리 성능을 높일 수 있고,
데이터 변경 불가능 로직임을 코드로 표시할 수 있어 가독성이 높아진다는 장점이 있다.

readOnly 속성이 없는 보통의 트랜잭션은 데이터 조회 결과 엔티티가 영속성 컨텍스트에 관리되며,
이는 1차 캐싱부터 변경 감지(Dirty Checking)까지 가능하게 된다.
하지만, 조회시 스냅샷 인스턴스를 생성해 보관하기 때문에 메모리 사용량이 증가한다.

+++추가 필요!!

🟧 JPA N + 1 문제와 발생하는 이유 그리고 해결하는 방법을 설명

N+1이란) 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것을 의미

해결 방법) 에는 여러 방법이 있지만 가장 많이 사용되는 방법은 Fetch Join을 사용해 해결

N+1 문제가 발생하는 이유는) 연관관계를 가진 엔티티를 조회할 때 한 쪽 테이블만 조회하고 연결된 다른 테이블은 따로 조회하기 때문인데,
Fetch Join을 사용하면 미리 두 테이블을 Join하여 한 번에 모든 데이터를 가져오기 때문에 N+1문제를 애초에 막을 수 있다.

🟡 JPA N+1 문제란?

1. When 언제 발생하는가?
JPA Repository를 활용해 인터페이스 메소드를 호출할 때(Read 시)

2. Who 누가 발생시키는가?
1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생

3. How 어떤 상황에 발생되는가?
JPA Fetch 전략이 EAGER 전략으로 데이터를 조회하는 경우
JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후에 연관 관계인 하위 엔티티를 다시 조회하는 경우

4. Why 왜 발생하는가?
JPA Repository로 find 시 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 하위 엔티티를 사용할 때 추가로 조회하기 때문에.
JPQL은 기본적으로 글로벌 Fetch 전략을 무시하고 JPQL만 가지고 SQL을 생성하기 때문에.

<EAGER(즉시 로딩)인 경우>
1. JPQL에서 만든 SQL을 통해 데이터를 조회
2. 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
3. 2번 과정으로 N + 1 문제 발생

<LAZY(지연 로딩)인 경우>
1. JPQL에서 만든 SQL을 통해 데이터를 조회
2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
3. 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N + 1 문제 발생

🟡 N+1 문제 해결 방법?

👉 1. Fetch Join(패치 조인)

N+1 자체가 발생하는 이유는 한쪽 테이블만 조회하고 연결된 다른 테이블은 따로 조회하기 때문이다.
미리 두 테이블을 JOIN 하여 한 번에 모든 데이터를 가져올 수 있다면 애초에 N+1 문제가 발생하지 않을 것이다.

그렇게 나온 해결 방법이 FetchJoin 방법이다.
두 테이블을 JOIN 하는 쿼리를 직접 작성하는 것이다.
다음과 같이 JPQL을 직접 지정해준다.

@Query("select DISTINCT o from Owner o join fetch o.pets")
List<Owner> findAllJoinFetch();
    @Test
    void test() {
        ...
        
        System.out.println("-------------------------------");
        List<Owner> ownerList = ownerRepository.findAllJoinFetch(); 
    }

Fetch Join(패치 조인)의 단점

  • 쿼리 한번에 모든 데이터를 가져오기 때문에 JPA가 제공하는 Paging API 사용 불가능(Pageable 사용 불가)
  • 1:N 관계가 두 개 이상인 경우 사용 불가
  • 패치 조인 대상에게 별칭(as) 부여 불가능
  • 번거롭게 쿼리문을 작성해야 함

👉 2. @Entity Graph

@EntityGraph 의 attributePaths는 같이 조회할 연관 엔티티명을 적으면 된다. ,(콤마)를 통해 여러 개를 줄 수도 있다.
Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.

@EntityGraph(attributePaths = {"pets"})
@Query("select DISTINCT o from Owner o")
List<Owner> findAllEntityGraph();

 @Test
    void test() {
        ...
        
        System.out.println("-------------------------------");
        List<Owner> ownerList = ownerRepository.findAllEntityGraph();
    }

Fetch Join과 @EntityGraph의 출력되는 쿼리를 보면 알다시피
Fetch join의 경우 inner join을 하는 반면에 EntityGraph는 outer join을 기본으로 한다.
(기본적으로 outer join 보다 inner join이 성능 최적화에 더 유리하다.)

🟡 Fetch Join과 EntityGraph 사용시 주의할 점
FetchJoin과 EntityGraph는 공통적으로 카테시안 곱(Cartesian Product)이 발생 하여 중복이 생길 수 있다.

※ 카테시안 곱 : 두 테이블 사이에 유효 join 조건을 적지 않았을 때 해당 테이블에 대한 모든 데이터를 전부 결합하여 테이블에 존재하는 행 갯수를 곱한만큼의 결과 값이 반환되는 것

중복 발생 문제를 해결하기 위한 방법은 다음과 같다.
👉 1. JPQL에 DISTINCT 를 추가하여 중복 제거

@Query("select DISTINCT o from Owner o join fetch o.pets")
List<Owner> findAllJoinFetch();
 
@EntityGraph(attributePaths = {"pets"})
@Query("select DISTINCT o from Owner o")
List<Owner> findAllEntityGraph();

👉 2. OneToMany 필드 타입을 Set으로 선언하여 중복 제거

@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
private Set<Pet> pets = new LinkedHashSet<>();

(Set은 순서가 보장되지 않는 특징이 있지만, 순서 보장이 필요한 경우 LinkedHashSet을 사용하자.)

profile
🇬🇧영국대학교)Computer Science학과 졸업 📚Data, AI, Backend 분야에 관심이 많습니다. 👉Email: kimbg9876@gmail.com

0개의 댓글