728x90

백준에서 처음으로 DFS와 BFS 문제를 접했는데 해설을 자세하게 해볼려고 한다.

 

문제 링크

1260번: DFS와 BFS

 

해설

N,M,V = map(int,input().split())

#행렬 만들기
graph = [[0]*(N+1) for _ in range(N+1)]
for i in range (M):
    a,b = map(int,input().split())
    graph[a][b] = graph[b][a] = 1

#방문 리스트 행렬
visited1 = [0]*(N+1)
visited2 = visited1.copy()

#dfs 함수 만들기
def dfs(V):
    visited1[V] = 1 #방문처리
    print(V, end=' ')
    for i in range(1, N+1):
        if graph[V][i] == 1 and visited1[i] == 0:
            dfs(i)

#bfs 함수 만들기
def bfs(V):
    queue = [V]
    visited2[V] = 1 #방문처리
    while queue:
        V = queue.pop(0) #방문 노드 제거
        print(V, end = ' ')
        for i in range(1, N+1):
            if(visited2[i] == 0 and graph[V][i] == 1):
                queue.append(i)
                visited2[i] = 1 # 방문처리

dfs(V)
print()
bfs(V)

 

 

1. 행렬 만들기

 

행렬을 만들 때 자주 사용되는 리스트 컴프리헨션 문법을 사용한다.

graph = [[0]*(N+1) for _ in range(N+1)]
for i in range (M):
    a,b = map(int,input().split())
    graph[a][b] = graph[b][a] = 1
[[0]*(N+1)] 은 N이 3이라면 [[0] * (4)] 이므로 => [[0,0,0,0]]을 출력한다. 
리스트 컴프리헨션을 써주면 [[0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0]]행렬이 완성된다.

graph =   0 1 2 3 행
	0 0 0 0 0
        1 0 0 0 0
        2 0 0 0 0
        3 0 0 0 0
        열
        
정점의 개수만큼의 행렬을 만들었다면,
이제는 정점 간의 연결을 행렬에 표시해 주어야한다.
만약 '1 2'가 입력된다면 정점1과 정점2가 연결되어있다는 뜻이므로 0을 1로 바꿔준다.

graph[1][2] = graph[2][1] = 1

graph =   0 1 2 3 행
	0 0 0 0 0
	1 0 0 1 0
	2 0 1 0 0
	3 0 0 0 0
	열

 

2. 방문 리스트 만들기

BFS와 DFS는 스택과 큐를 사용한다. 이때 스택과 큐에 한번 들어갔던 노드는 다시 들어가지 못한다.

즉, 이 노드가 스택과 큐에 들어갔던 적이 있는지 확인할 필요가 있다. 이를 표현하기 위해 정점의 수만큼의 방문 리스트를 만들겠다.

visited1 = [0]*(N+1)
visited2 = visited1.copy()

N이 3이라면, visited1 = [0,0,0,0]
만약 1번 노드가 스택에 들어간 적이 있다면 visited = [0,1,0,0] 이 될 것이다.

 

3. DFS 함수 만들기 - 재귀 사용

def dfs(V):
    visited1[V] = 1 #방문처리
    print(V, end=' ')
    for i in range(1, N+1):
        if graph[V][i] == 1 and visited1[i] == 0:
            dfs(i)
            
탐색을 시작하는 번호 V부터 탐색을 시작한다.
먼저 V노드가 시작이므로 V를 방문 처리한다. 
예를들어 V=1이라면 위에서 만들어놓은 방문 리스트에서 visited1[1] = 1 로 방문처리하는 것이다.
그러면 visited1 = [0,1,0,0]이 될 것이다.
이는 1번 노드가 스택에 들어간적이 있다는 기록인 것이다.

1번 노드가 방문했으므로 1번 노드를 출력한다.
print(V, end=' ')

이 후 for문을 돌려서 if graph[V][i] == 1로
graph[1][1], graph[1][2], graph[1][3]을 확인해 줄 것이다.
이는 1과 연결된 노드를 찾는 것이다. (위에서 행렬로 연결된 노드들을 1로 표시해두었으므로)

만약 1과 2가 연결돼있다면 graph[1][2] == 1에 True가 될 것이다.
여기서 and visited1[i] == 0으로 2번 노드가 스택에 들어갔던 적이 있는지 확인해주어야 한다.
만약 2번 노드가 스택에 들어간적이 있다면 만들어놓은 방문리스트에서 1값으로 변경되었을 것이다.

즉, 1과 연결된 노드 중 방문기록이 없는 노드를 찾는 코드이다.
if graph[V][i] == 1 and visited1[i] == 0:

1과 연결된 노드 중 방문기록이 없는 노드가 있다면
dfs(i)로 재귀함수를 돌린다.

dfs(2)는 다시 위의 과정을 돌려서 2를 방문처리하고, 2와 연결된 노드를 찾는다.
이렇게 계속 연결된 노드들을 찾아 '깊이우선탐색'을 진행하는 것이다.  

 

4. BFS 함수 만들기

def bfs(V):
    queue = [V]
    visited2[V] = 1 #방문처리
    while queue:
        V = queue.pop(0) #방문 노드 제거
        print(V, end = ' ')
        for i in range(1, N+1):
            if(graph[V][i] == 1 and visited2[i] == 0):
                queue.append(i)
                visited2[i] = 1 # 방문처리
      
      
bfs는 queue를 이용한다.
탐색 시작 노드 V가 주어진다면
queue = [V] 로 큐에 탐색 노드를 먼저 넣는다.
이 후, 위에서 만들어놓은 방문리스트 visited2[V] = 1로 방문 처리해준다.
V가 1이라면 visited2 = [0,1,0,0]이 될 것이다.
dfs와 같은 원리이다.

이제 while queue: 로 queue에 값이 없을때까지(탐색이 끝날때까지) 반복할 것이다.
먼저 queue.pop(0)을 해준다. 이 코드는 queue에서 0번째 요소를 돌려주고 삭제하라는 것이다.
queue는 선입선출 구조이므로 가장 먼저들어온 0번째 요소부터 빼는 것이다.
그리고 삭제한 요소를 V변수로 돌려받겠다.
위에서 V를 1로 가정했을 때 queue = [1], visited[2] = [0,1,0,0]인 상태이고
여기서 V = queue.pop(0)을 해주면 queue = []이 되고, V = 1이 될 것이다.
1을 뺐으니 1을 출력한다. print(V, end = ' ')

이 후 for문으로 연결된 노드들을 탐색해줄 것이다.
원리는 위에 설명한 dfs와 같다.
V와 연결되고, 방문한 적이 없는 노드가 있다면
큐에 넣어줄 것이다.


bfs과정을 나열해보겠다.
V = 1 // 1번 노드부터 탐색시작
queue = [1] // 방문 노드 저장
visited2[1] = 1 // visited2 = [0,1,0,0]

while문 들어가서,
queue = []
V = 1 // 1출력
for문 들어가서,
노드 1과 2가 연결되고, 노드 2가 방문된적이 없다면
graph[1][2] ==1 and visited2[2] == 0에 해당하므로
queue = [2]
visited2[2] = 1로 방문처리

노드 1과 3이 연결되고, 노드 3이 방문된적이 없다면
queue = [2,3]
visited2[3] = 1로 방문처리
모든 연결 노드 확인 후 for문 반복 끝

queue에 값이 있으므로
while문 탈출 안하고 다시 반복
V = 2 // 2출력
queue = [3]
graph[2][1] == 1 and visited2[2] ==0에 해당하지 않으므로 큐에 다시 넣지 못한다.(2는 1과 연결돼있지만, 1은 이미 큐에 들어간 기록이 있다.)
graph[2][3] == 1 and visited2[2] ==0 역시 마찬가지로 방문기록이 있으므로 false

이 경우 아무것도 추가되지 않고 for문을 빠져나간다.
queue = [3]으로 아직 값이 있으므로 while문 다시 반복
V = 3 
queue = []

 

5. BFS 함수 더 이해하기

queue는 pop(0)을 통해 먼저 들어온 요소를 뺀다.
선입선출인 큐가 너비우선탐색을 하게하는 핵심이다.

아래와 같이 연결된 노드가 있다고 가정해보자.
(1-2,1-3,1-4,3-5,4-6,6-7 연결)

1 - 2 
  - 3 - 5 
  - 4 - 6 - 7
  
  
queue에는 시작 노드를 먼저 담고 시작한다.
queue = [1]로 시작해서 1을 뺀 후 출력한다.
// queue = []
// 출력: 1
이 후 1과 연결된 2,3,4 가 queue에 담길 것이다.

// queue = [2,3,4]
0 번째 요소 (가장 먼저들어온) 2번 노드를 빼고 출력한다.
// 출력: 1 2
2와 연결된 노드를 확인한다.
2와 연결된 노드가 없다면 추가되지 않는다.

// queue = [3,4]에서
0 번째 요소 3번 노드를 빼고 출력한다.
// 출력: 1 2 3
3과 연결된 노드를 확인한다.
3과 연결된 5가 queue에 담길 것이다.

// queue = [4,5]
0 번째 요소 4번 노드를 빼고 출력한다.
// 출력: 1 2 3 4
4와 연결된 노드를 확인한다.
4와 연결된 6이 queue에 담길 것이다.

// queue = [5,6]
0 번째 요소 5번 노드를 빼고 출력한다.
// 출력: 1 2 3 4 5
5와 연결된 노드를 확인한다.
5와 연결된 노드가 없다면 추가되지 않는다.

// queue = [6]
0 번째 요소 6번 노드를 빼고 출력한다.
// 출력: 1 2 3 4 5 6
6과 연결된 노드를 확인한다.
6과 연결된 7이 queue에 담길 것이다.

// queue = [7]
0 번째 요소 7번 노드를 빼고 출력한다.
// 출력: 1 2 3 4 5 6 7
7과 연결된 노드를 확인한다.
7과 연결된 노드가 없다면 추가되지 않는다.

queue = [] 빈 문자열이므로 while 반복문을 달출한다.
728x90
728x90

위의 그림은 DFS와 BFS 경로를 순서대로 나타낸 것이다. 해당 그림을 통해 직관적으로 둘의 차이를 알 수 있을 것이다. 깊이 우선 탐색인 DFS는 가장 깊은 곳까지 방문하고 스택을 통해 구현한다.

너비우선탐색인 BFS는 같은 레벨 인접 노드를 전부 방문한 뒤 다음 레벨 인접 노드를 방문한다. 큐를 통해 구현한다.

 

 


깊이우선탐색 DFS

  • 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘이다.

구현 방법

1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.
2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.
3. 2번 과정을 더 이상 수행할 수 없을 때까지 반복한다.

  • 해당 그래프를 스택을 이용한다.
  • 스택은 한 방향에서만 자료를 넣고 뺄 수 있는 후입선출 방식 구조이므로 가장 늦게 들오온 노드를 가장 먼저 뺄 수 있다.
  • 또한 한 번 담았던 노드는 다시 담지 않는다.

1. 먼저 시작 노드인 0번 노드를 스택에 담았다.

2. 이후 0번 노드를 꺼내 출력하고 그 인접 노드인 1번 노드를 스택에 담았다. 

3. 이후 1번 노드를 꺼내 출력하고 그 인접 노드인 2번 노드와 3번 노드를 스택에 담았다.

4. 이후 3번 노드를 꺼내 출력하고 그 인접 노드인 4번 노드와 5번 노드를 스택에 담았다. 2번 노드는 이미 스택에 담겨있으므로 스택에 다시 추가하지 않는다.

5. 이후 5번 노드를 꺼내 출력하고 그 인접 노드인 6번 노드와 7번 노드를 스택에 담았다. 

6. 이후 7번 노드를 꺼내 출력하고 인접 노드가 없으므로 더 담지 않는다.

7. 이후 6번 노드를 꺼내 출력하고 그 인접 노드인 8번 노드를 스택에 담았다.

8. 이후 8번 노드를 꺼내 출력하고 인접 노드가 없으므로 더 담지 않는다.

9. 이후 4번 노드를 꺼내 출력하고, 2번 노드를 꺼내 출력한다, 더 꺼낼 노드가 없으므로 순회는 종료한다.

 

DFS 경로 : 0 > 1 > 3 > 5 > 7 > 6 > 8 > 4 > 2

출력 순서와 과정은 스택이기 때문에 위와 같이 이루어지는 것이다. 

 


 

너비우선탐색 BFS

그래프에서 가까운 노드부터 탐색하는 알고리즘이다.

구현방법

1. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.
2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.
3. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

  • 해당 그래프를 BFS로 구현해보자. 
  • BFS는 Queue를 이용한다. 
  • Queue는 가장 먼저 들어온 것이 가장 먼저 나가는 선입선출 방식의 구조이다. 
  • DFS 구현과 마찬가지로 한번 큐에 담았던 노드는 다시 담지 않는다.

1. 먼저 시작 노드인 0번 노드를 큐에 담았다.

2. 이 후 0번 노드를 꺼내 출력하고, 그 인접 노드인 1번 노드를 큐에 담았다.

  • 큐는 선입선출이므로 꺼내는 방향이 그림의 화살표처럼 아래부터이다.

3. 이 후 1번 노드를 꺼내 출력하고, 그 인접 노드인 2번 노드와 3번 노드를 큐에 담았다.

4. 이 후 2번 노드를 꺼내 출력하고, 그 인접 노드인 4번 노드를 큐에 담았다.
큐는 선입선출 방식이므로 가장 아래 있는 2번 노드부터 꺼낸다. 또한, 그 인접 노드 중 1번, 3번은 이미 큐에 들어갔었으므로 4번 노드만 큐에 담긴다.

5. 이 후 3번 노드를 꺼내 출력하고, 그 인접 노드인 5번 노드를 큐에 담았다.

6. 이 후 4번 노드를 꺼내 출력하고, 그 인접 노드는 전부 큐에 담았던 적이 있으므로 다시 담지 않는다.

7. 이 후 5번 노드를 꺼내 출력하고, 그 인접 노드인 6번 노드와 7번 노드를 큐에 담았다.

8. 이 후 6번 노드를 꺼내 출력하고, 그 인접 노드인 8번 노드를 큐에 담았다.

9. 이 후 7번 노드를 꺼내 출력하고, 8번 노드를 꺼내 출력한다. 더 꺼낼 노드가 없으므로 순회는 종료한다.

 

 

 

BFS 경로 : 0 > 1 > 2 > 3 > 4 > 5 > 6 > 7 > 8

728x90
728x90

개요

스터디 중에 AOP에 대한 압박 질문을 받았는데 하나도 대답을 못해서 정리할려고 한다...ㅎㅅㅎ

 


 

1. 사용 목적

애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있다. 

  • 핵심 기능은 해당 객체가 제공하는 고유의 기능
  • 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다.(로그 추적 기능, 트랜잭션 기능 등...)

보통 기존 프로젝트에 부가 기능을 추가하게 되면 하나의 클래스가 아닌 여러 클래스에 부가 기능을 추가하게 된다.

 

예를 들어서 프로젝트의 모든 클래스에 로그 기능을 추가한다면 하나의 부가 기능(로그 추적)을 여러 곳에 동일하게 사용하게 된다.

 

이러한 부가 기능횡단 관심사라고 한다.

 

1.1 단점

하지만 이러한 기존 프로젝트에 부가 기능을 추가하게 된다면 여러 문제점이 있다. 만약 부가 기능을 적용해야 할 클래스가 100개 라면 100개에 모두 똑같은  부가 기능 코드를 추가해야 하고 거기에 더해 단순 호출이 아닌 try- catch-finally 구문이 필요하다면 더욱 복잡해질 것이다. 

이렇게 많은 클래스에 부가 기능을 힘들게 추가했다고 가정한 후 만약 수정이 필요하다면,,,? 또 100개의 클래스를 하나씩 뜯어 고쳐야 한다는 노가다 과정을 겪어야 한다.

 

 

2. AOP란?

이러한 부가 기능을 적용할 때 위와 같은 문제점이 발생한다. 그래서 많은 개발자들은 오랜 시간 고민해왔다. 고민 끝에 AOP라는 용어가 나오게 되었다.

AOP는 관점 지향 프로그래밍의 약어로 부가 기능을 핵심 기능에서 분리해 한 곳으로 관심하도로 하고 이 부가 기능을 어디에 적용할지 선택하는 기능을 합한 하나의 모듈이다. 

 

 

 

3. AOP 용어 정리

AOP 프록시

AOP 기능을 구현하기 위해 만든 프록시 객체, 스프링에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.

 

조인 포인트 ( Join Point )

어드바이스가 적용될 수 있는 위치로, AOP를 적용할 수 있는 모든 지점이라 생각하면 된다.

스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메소드 실행 지점으로 제한된다.

 

포인트컷 ( Pointcut )

조인 포인트 중에서 어드바이스(부가 기능)를 어디에 적용할 지, 적용하지 않을 지 위치를 판단하는 필터링하는 기능 ( 주로 AspectJ 표현식을 사용해서 지정 )

프록시를 사용하는 스프링 AOP는 메서드 실행 지점을 포인트 컷으로 필터링 한다.

 

타겟 ( Target )

어드바이스를 받는 객체, 포인트컷으로 결정

 

어드바이스 ( Advice )

부가 기능

특정 조인 포인트에서 Aspect에 의해 취해지는 조치

Around(주변), Before(전), After(후)와 같은 다양한 종류의 어드바이스가 있음

 

애스펙트( Aspect )

어드바이스 + 포인트컷을 모듈화 한 것

하나의 어드바이스만이 아닌 여러 어드바이스와 포인트 컷이 함께 존재할 수 있다.

 

어드바이저 ( Advisor )

하나의 어드바이스와 하나의 포인트 컷으로 구성

즉, 어드바이스 + 포인트 컷 = 어드바이저

 

 

 

4. AOP 적용 방식

AOP의 적용 방식은 크게 3가지가 있다.

  • 컴파일 시점
  • 클래스 로딩 시점
  • 런타임 시점 ( 프록시 사용 )

컴파일 시점과 클래스 로딩 시점 적용 방식은 AspectJ 프레임워크를 직접 사용해야 하고, 이 AspectJ를 학습하기 위해선 엄청난 분량과 설정의 번거로움이 있다. 그래서 주로 런타임 시점 적용 방식을 사용하는 스프링 AOP를 사용 한다.

 

 

 

 

5. AOP 적용 가능 위치

주로 사용하는 런타임 시점 적용 방식의 스프링 AOP 관점에서의 방식이다. 

  • 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
  • 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너에 해당 @Aspect을 빈 등록을 해야 AOP를 적용할 수 있다.

 

 

6. 스프링에서 AOP 적용하기 (@Aspect)

스프링 AOP를 적용하기 위해서는 아래의 라이브러리를 build.gradle에 의존성을 추가 해줘야 한다.

implementation 'org.springframework.boot:spring-boot-starter-aop' // 스프링 aop 추가

위의 라이브러리를 추가하면 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록해준다.

이러한 스프링 부트의 자동 설정은 "AnnotationAwareAspectJAutoProxyCreator"라는 빈 후처리기가 스프링 빈에 자동으로 등록해주는데, 이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기이다.

 

6.1 AnnotationAwareAspectJAutoProxyCreator란? 

1. @Aspect 어노테이션이 붙은 클래스를 Advisor(어드바이저)로 변환하여 저장

2. Advisor(어드바이저)를 자동으로 찾아와 프록시를 생성하고 Pointcut(프록시 적용 대상 필터링)을 보고 프록시가 필요한 곳에 Advice(부가 기능)을 적용

 

 

6.2 코드 - @Around 

  • 스프링은 @Aspect 애노테이션으로 매우 편리하게 포인트컷과 어드바이스로 구성되어 있는 어드바이저 생성 기능을 지원한다.
  • 어드바이저로 사용할 클래스에 @Aspect 어노테이션을 붙여줌으로써 스프링 AOP를 적용할 수 있다.
@Aspect
public class LogTraceAspect {
 
    @Around("execution(* hello.proxy.app..*(..))")  // 포인트컷 (AspectJ 표현식)
    public Object execute(ProceedingJoinPoint joinPoint) { // 어드바이스
        // 어드바이스 로직
    }
}

@Around 어드바이스를 사용할 경우 메서드의 파리미터로 "ProceedingJoinPoint"를 꼭 넣어줘야 한다.

ProceedingJoinPoint의 proceed()는 다음 어드바이스나 타켓을 호출하는 것으로, 어드바이스를 사용하기 위해서는 꼭 proceed() 메서드를 호출해줘야 한다.

 

이 외에도  호출되는 대상 객체에 대한 정보, 실행되는 메서드에 대한 정보 등이 필요할 때가 있는데 이 경우에는

ProceedingJoinPoint 인터페이스가 제공하는 아래의 메서드를 사용할 수 있다. (이 외에도 더 있지만 일부만 설명)

메서드 설 명
Signature getSignature()  호출되는 메서드에 대한 정보를 반환
Object getTarget()  대상 객체를 반환
String getName  메서드의 이름을 반환
String toLongString()  메서드를 완전하게 표현한 문장을 반환 (메서드의 리턴 타입, 파라미터 타입 모두 표시)

 

이렇게  ProceedingJoinPoint 호출되는 객체에 대한 정보나, 실행되는 메서드의 정보 알 수 있는 이유

스프링 부트 자동 설정으로 "AnnotationAwareAspectJAutoProxyCreator" 이라는 자동 프록시 생성기가 빈 등록되어 있는데,

이 자동 프록시 생성기가 @Aspect가 붙은 클래스를 보고 Advisor(어드바이저)로 변환해 저장해준다.

그리고 이 Advisor(어드바이저)를 보고 포인트컷의 대상이 되는 것들을 "ProxyFactory"에 인자로 넘겨 자동으로 프록시를 생성하고 적용해준다.

여기서 생성된 프록시 객체가 메서드를 호출할 때, ProceedingJoinPoint 객체를 생성하고 이를 advice에 전달한다.

즉, ProceedingJoinPoint는 프록시가 메서드를 호출하는 시점의 정보를 가져 어드바이스가 적용되는 대상을 이미 알고 있다.

 

 

6.3 @Pointcut

@Around 에 포인트컷 표현식을 직접 넣을 수 도 있지만, @Pointcut 애노테이션을 사용해서 별도로 분리해 재사용할 수 도 있다.

@Slf4j
@Aspect
@Component
public class AspectExample {
 
    // hello.aop.test 패키지와 하위 패키지에 적용
    @Pointcut("execution(* hello.aop.test..*(..))")
    private void allTestLog() {} //  포인트컷 시그니쳐 
 
    @Around("allTestLog()") // 포인트컷을 메서드로 만들어 사용
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처
        return joinPoint.proceed(); // 실제 타깃 호출
    }
}

 

 

 

7. 어드바이스 종류

  • @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능 (사실 이거 하나만 사용해도 무방)
  • @Before : 조인 포인트 실행 이전에 실행
  • @AfterReturning : 조인 포인트가 정상 완료후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

@Around를 제외한 나머지 어드바이스들은 JoinPoint를 첫 번째 파라미터에 사용한다. (생략도 가능)

( JoinPoint는 ProceedingJoinPoint의 부모 타입)

 

728x90
728x90

스프링 배치를 사용하여 시간 기반 트리거로 특정 조건(읽지 않은 알림이 일주일 지난 경우 다시 알림 발송)을 구현했을 때 다중 인스턴스 환경에서는 인스턴스마다 배치 작업이 중복 실행되는 문제가 발생할 수 있다. 그 외로 스프링 배치 관련해서 어떤 질문이 나올지 정리할려고 한다.

 

 

 

 

예상 질문 1: "인스턴스가 두 대 이상일 때, 알림이 중복 발송되는 문제를 어떻게 해결할 수 있나요?"

 

답변:

"스프링 배치에서 다중 인스턴스 환경에서 중복 작업이 발생하지 않도록 하기 위해서는 배치 작업의 동시성 제어가 필요합니다. 이 문제를 해결하기 위해 다음과 같은 방법을 사용할 수 있습니다.

  1. Spring Batch의 JobInstance 및 JobExecution을 활용한 중복 방지: 스프링 배치는 기본적으로 JobInstance라는 개념을 사용하여 동일한 파라미터로 실행된 배치 작업이 여러 번 실행되지 않도록 방지합니다. 이를 통해 동일한 작업이 두 인스턴스에서 동시에 실행되지 않도록 제어할 수 있습니다. 특정 배치 작업에 고유한 ID나 파라미터를 부여하여 같은 작업이 중복 실행되지 않도록 처리할 수 있습니다.
  2. 쿼츠 스케줄러(Quartz Scheduler)를 사용한 클러스터 환경 지원: 클러스터링된 환경에서 배치 작업이 중복 실행되지 않도록 Quartz Scheduler를 사용할 수 있습니다. 쿼츠 스케줄러는 분산된 여러 인스턴스에서 하나의 작업만 실행되도록 스케줄링을 관리할 수 있는 기능을 제공합니다. 이를 통해 각 인스턴스가 스케줄링된 작업의 소유권을 체크하고, 작업을 중복 실행하지 않도록 보장할 수 있습니다.
  3. DB를 활용한 분산 락(Distributed Lock): 배치 작업이 실행되기 전에 데이터베이스를 활용하여 분산 락(Distributed Lock)을 구현할 수 있습니다. 락을 획득한 인스턴스만 배치 작업을 실행하게 하고, 나머지 인스턴스는 작업을 대기하거나 실행하지 않도록 처리할 수 있습니다. Redis와 같은 캐시 서버의 락 기능을 활용해 배치 트리거를 하나의 인스턴스에서만 실행할 수 있도록 구현할 수 있습니다."

=> 분산락이 제일 많이 사용되는 방

 

예상 질문 2: "분산 락을 사용하는 방법은 무엇인가요?"

 

답변:

 

분산 락(Distributed Lock)은 여러 대의 서버나 인스턴스가 동시에 동일한 자원에 접근할 때 하나의 인스턴스만 자원에 접근할 수 있도록 제어하는 메커니즘이다. 분산 시스템에서 여러 서버나 인스턴스가 동일한 작업을 중복해서 수행하지 않도록 하는 것이 목적이다.

서버 A와 서버 B가 동시에 동일한 데이터베이스 테이블을 갱신하려고 하면 충돌이나 데이터 불일치가 발생할 수 있습니다. 이때 분산 락을 통해 한 번에 하나의 서버만 해당 테이블을 갱신할 수 있도록 제어하는 것이다.

 

분산 락의 주요 개념

  1. 락 획득: 특정 자원을 사용하기 전에 인스턴스가 락을 먼저 획득해야 한다. 락을 획득한 인스턴스는 해당 자원에 대해 독점적으로 작업할 수 있다.
  2. 락 해제: 작업이 완료되면 락을 해제하여 다른 인스턴스가 자원에 접근할 수 있도록 한다.
  3. TTL(Time To Live): 락이 너무 오래 걸리거나, 시스템 오류로 인해 락이 해제되지 않을 경우를 대비해 일정 시간이 지나면 자동으로 락을 해제하는 방식이다.

Redis를 사용한 분산 락 예시

  1. setnx 명령: Redis의 setnx(Set if Not Exists) 명령은 주어진 키가 존재하지 않을 때만 값을 설정하는 명령이다. 이를 통해 하나의 인스턴스만 특정 키에 락을 설정할 수 있게 된다.
  2. TTL 설정: 만약 배치 작업이 실패하거나 예기치 못한 오류가 발생할 경우 락이 계속 유지되는 것을 방지하기 위해 TTL을 설정하여 자동으로 락이 해제되도록 한다.
  3. 락 해제: 작업이 완료되면 해당 키를 삭제하여 락을 해제한다.

분산 락이 필요한 이유

분산 환경에서 여러 인스턴스가 동일한 자원에 동시에 접근하게 되면 데이터 일관성 문제가 발생하거나 작업이 중복으로 실행될 수 있다. 분산 락은 이를 방지하고 자원을 안전하게 제어하여 시스템의 안정성을 높이는 데 중요하다.

이러한 락을 잘못 관리하면 데드락(Deadlock) 같은 문제가 생길 수 있으므로 락의 적절한 관리가 필요하다.


"분산 락을 사용하여 여러 인스턴스에서 배치 작업이 중복되지 않게 제어하는 방법은 보통 데이터베이스Redis와 같은 캐시 시스템을 활용하는 방식입니다. 그 중 하나인 Redis를 사용한 분산 락 예를 들면:

배치 작업을 실행할 때 Redis의 setnx 명령을 사용하여 락을 생성합니다. 이 락은 하나의 인스턴스만 획득할 수 있으며, 다른 인스턴스들은 이 락을 획득하지 못하게 됩니다.

락을 획득한 인스턴스가 배치 작업을 실행하고, 작업이 끝나면 락을 해제합니다.

TTL(Time To Live)을 설정하여, 만약 작업이 예상보다 오래 걸리거나 예기치 못한 상황이 발생하더라도 락이 자동으로 해제되도록 할 수 있습니다.

이 방법을 사용하면 하나의 인스턴스만 작업을 실행하므로, 알림이 중복 발송되는 문제를 방지할 수 있습니다."

 

 

예상 질문 3: "스프링 배치에서 트랜잭션 관리를 어떻게 처리했나요?"

 

답변:

"스프링 배치는 기본적으로 트랜잭션 관리를 지원하며, 각 단계(Step)마다 트랜잭션 경계를 설정할 수 있습니다. 알림 발송과 같은 중요한 작업에서는 데이터의 일관성을 보장하기 위해 트랜잭션을 적용하여 작업이 성공적으로 완료되지 않으면 롤백하도록 설정했습니다. 예를 들어, 읽지 않은 알림을 조회하고 다시 알림을 보내는 과정에서 문제가 발생할 경우, 해당 작업은 롤백되어 알림이 중복되거나 잘못된 상태로 저장되지 않도록 했습니다.

또한, 알림을 발송한 후 성공적으로 처리된 데이터에 대해서만 커밋이 이루어지도록 하여 데이터 일관성을 유지했습니다."

 

 

예상 질문 4: "만약 한 인스턴스에서 배치 작업 중 오류가 발생하면 어떻게 처리할 수 있나요?"

 

답변:

"배치 작업 중 오류가 발생할 수 있는 다양한 상황에 대비해 스프링 배치의 재시도 및 실패 처리 메커니즘을 적용했습니다. RetryTemplate을 사용하여 특정 단계에서 오류가 발생했을 때, 자동으로 재시도하도록 구성하였습니다. 오류가 발생해도 일정 횟수 동안 재시도할 수 있는 구조를 쉽게 구현할 수 있습니다. 또한, SkipPolicy를 설정하여 일정 횟수 이상의 오류가 발생한 경우 해당 배치 작업을 중단하고, 오류를 로깅하거나 별도의 실패 처리 로직을 실행할 수 있습니다.

이외에도, 실패한 작업을 별도의 에러 큐에 넣어 나중에 다시 처리할 수 있도록 설정할 수 있습니다. 이렇게 하면 시스템 장애나 예기치 않은 오류 발생 시에도 작업이 중단되지 않고, 안정적으로 복구할 수 있도록 대비할 수 있습니다."

 

기본적으로 RetryTemplate을 사용해서 배치 오류 해결을 위해 재전송을 사용하는데 트랜잭션 관리로도 가능하다.

배치 작업의 각 스텝에 대해 트랜잭션을 설정하여 작업 중 오류가 발생하면 트랜잭션을 롤백하고 작업을 재시도하지 않도록 할 수 있다. 트랜잭션을 사용하면 배치 작업에서 일부 데이터가 처리된 후 오류가 발생해도 처리된 데이터가 커밋되지 않게 하여 데이터 일관성을 유지할 수 있다.

728x90
728x90

개요

스프링 배치를 사용해서 알림 재전송 기능을 구현했을 때 다양한 방식들의 트리거링 방식에 대해서 정리할려고 한다.

 

 

배치 트리거링(Batch Triggering)

 

  • 배치 트리거링(Batch Triggering)은 특정 조건이나 이벤트가 발생할 때 자동으로 배치 작업을 시작하는 메커니즘을 말한다.
  • 배치 작업은 대량의 데이터를 처리하거나 주기적으로 실행되는 작업을 자동화하는 데 사용되는데 이때 트리거링을 통해 작업 시작을 제어한다.

 

 

배치 트리거링 방식

  • 시간 기반 트리거(Time-based Trigger)
    • 특정 시간이나 주기로 배치 작업이 실행되도록 설정하는 방식이다.
    • 매일 자정에 데이터 백업 작업을 실행하는 경우
  • 이벤트 기반 트리거(Event-based Trigger)
    • 특정 이벤트가 발생할 때 배치 작업을 실행하는 방식이다.
    • 사용자가 주문을 완료하면 해당 주문 정보를 처리하는 배치 작업이 실행되는 경우
  • 조건 기반 트리거(Condition-based Trigger)
    • 특정 조건이 충족될 때 배치 작업을 실행하는 방식이다.
    • 데이터베이스에 일정한 양의 데이터가 쌓이면 이를 처리하는 배치 작업이 실행되는 경우

 

배치 트리거링은 시스템 효율성을 높이고 사용자가 수동으로 작업을 실행하지 않아도 자동으로 필요한 처리가 이뤄지도록 돕는 중요한 개념이다.!!!!

728x90
728x90

개요

1편에서 배치에 대해 살펴보았고 이제 내가 하는 프로젝트에 배치를 적용해볼 예정이다. 

 

일단 나는 SSE 알림 기능을 구현했는데, 일정 기간동안 읽지 않은 알림이 있다면 사용자에게 "읽지 않은 알림이 있습니다." 라는 문구를 띄어줄려고 한다. 주기적인 스케줄을 가지고 필요한 알림을 처리하기 위한 기술이 필요했다. 그래서 주기적인 schedule 에 따라 Spring Batch를 통해서 필요 데이터를 reading, 알람을 보내는 기능을 구현하기로 했고 알람은 SSE를 사용하기로 했다.

 

사용자가 접근하지 않는 시간대인 매일 자정에 매번 실행해서 7일동안 읽지않은 알림이 존재할 경우 알림을 보내는 역할을 할 것이다.

 

 


설정

 

build.gradle

// spring batch
implementation 'org.springframework.boot:spring-boot-starter-batch'

batch 의존성 추가

 

 

application.yml

spring:
  application:
    name: {name}

  batch:
    jdbc:
      initialize-schema: always #batch 스키마 자동 생성
    job:
      enabled: false #시작과 동시에 실해되는건 방지

 

 

 

Cofing.java

@Configuration
@EnableBatchProcessing
@Slf4j
@RequiredArgsConstructor
public class NotificationBatchConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager platformTransactionManager;
    private final NotificationRepository notificationRepository;

    // 배치 작업의 전체 실행
    @Bean
    public Job deleteNotificationsJob(Step deleteUnreadNotificationsStep) {
        log.info(">>> Creating deleteNotificationsJob");
        return new JobBuilder("deleteNotificationsJob", jobRepository)
                .start(deleteUnreadNotificationsStep)
                .build();
    }

    // 배치 작업 내 실행되는 개별 처리
    @Bean
    public Step deleteUnreadNotificationsStep() {
        log.info(">>> Creating deleteUnreadNotificationsStep");
        return new StepBuilder("deleteUnreadNotificationsStep", jobRepository)
                .tasklet(deleteUnreadNotificationsTasklet(), platformTransactionManager)
                .build();
    }

    // 10분 이상 읽지 않은 알림 삭제
    @Bean
    public Tasklet deleteUnreadNotificationsTasklet() {
        return (contribution, chunkContext) -> {
            LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(10);
            List<Notification> notificationsToDelete = notificationRepository.findByIsReadAndCreatedAtBefore(false, cutoffTime);
            notificationRepository.deleteAll(notificationsToDelete);
            log.info("Deleted unread notifications older than 10 minutes");
            return RepeatStatus.FINISHED;
        };
    }
}

 

 

Tasklet

  • 일반적인 Component와 비슷한 개념, 개발자가 지정한 커스텀한 기능을 위한 단위
  • 개발자가 이 STEP에서 하고 싶은 내용을 자유롭게 만들 수 있다.

 

 

BatchScheduler.java

@Component
@Slf4j
@RequiredArgsConstructor
public class NotificationBatchScheduler {

    private final JobLauncher jobLauncher; // 정의된 job 을 주기적 실행
    private final Job deleteNotificationsJob;

    @Scheduled(cron="0 0 12 * * *", zone = "Asia/Seoul") // 매일 자정에 실행
    public void runDeleteUnreadNotificationsJob() {
        try {
            jobLauncher.run(deleteNotificationsJob, new JobParametersBuilder()
                    .addLong("timestamp", System.currentTimeMillis())
                    .toJobParameters());
            log.info("Notification deletion job executed successfully.");
        } catch (Exception e) {
            log.error("Failed to execute notification deletion job", e);
        }
    }
}

 

jobLauncher.run()메서드는 첫번째 파라미터로 Job, 두번째 파라미터로 Job Parameter를 받고있다.
Job Parameter의 역할은 반복해서 실행되는 Job의 유일한 ID이다.

Launcher 가 어떤 배치 작업(Job)을 실행할 지에 대해 작성한다. 

Job은 어떤 데이터들을 통해서 어떤 작업을 실행할 지, 실행 순서 등에 대한 내용(Step)을 담고 있어야 한다.

 

 

Scheduler

  • batch를 수행하기 위한 Scheduler를 파일에 구성(매일 자정에 실행)
Spring Batch는 Batch Job을 관리하지만 `Job`을 구동하거나 실행시키는 기능은 지원하고 있지않다.
Spring에서 Batch Job을 실행시키기 위해서는 `Quartz, Scheduler`와 같은 전용 Scheduler를 사용해야한다.
728x90
728x90

 

 

 

배치란? 스프링 배치란? 

  • 배치 는 일정 시간마다 또는 특정 조건에 따라 대규모 데이터를 일괄 처리하는 방식을 의미한다.
  • 스프링 배치(Spring Batch)는 이러한 배치 작업을 보다 쉽게 구현하고 관리할 수 있도록 돕는 프레임워크이다.
  • 스프링 배치는 스프링 생태계의 일환으로, 스프링의 강력한 기능을 활용하여 배치 작업을 안정적이고 효율적으로 수행할 수 있도록 지원한다.
  • 이를 통해 개발자는 복잡한 배치 처리 로직을 간결하게 구현하고 모니터링 기능을 통해 배치 작업을 효과적으로 관리할 수 있다.

 

스프링 배치와 스프링 스케줄러 차이

  • 많은 개발자들이 이 두 가지를 혼동하곤 하지만, 스프링 배치(Spring Batch)와 스프링 스케줄러(Spring Scheduler)는 서로 다른 목적과 기능을 가지고 있다.

 

스프링 배치

  • 대량의 데이터를 효율적으로 처리하기 위해 설계된 프레임워크
  • 복잡한 배치 작업을 구성하고 실행하기 위한 다양한 도구와 기능을 제공
    • 배치 프로세스를 주기적으로 커밋: 막강한 커밋 전략을 제공
    • 동시 다발적인 job 의 배치 처리, 대용량 병렬 처리
    • 실패 후 수동 또는 스케줄링에 의한 재시작: 오류에 대한 대처 옵션을 제공
    • 반복, 재시도, skip 처리 : 실패한 배치 작업을 중단된 지점부터 다시 시작하거나 skip 할 수 있다.
    • 모니터링: 배치 작업의 진행 상태와 소요 시간 등의 정보를 제공
    • 트랜잭션 관리: 트랜잭션 관리를 지원하여 데이터의 일관성 유지

 

스프링 배치는 주로 금융, 전자상거래, 건강관리 등 대량의 데이터 처리가 필요한 도메인에서 널리 사용된다.

배치(batch)는 데이터를 실시간으로 처리하는게 아니라, 일괄적으로 모아서 한번에 처리하는 작업을 의미한다.

 

 

스프링 스케줄러

  • 스프링 스케줄러는 특정 시간에 작업을 실행하기 위해 설계된 도구이다.
  • 반복적인 작업을 자동화하고, 시간 기반의 작업을 쉽게 설정할 수 있다.
    • 시간 기반 작업 실행: 특정 시간 또는 주기적으로 작업을 실행할 수 있다.
    • 다양한 스케줄링 방식: 크론 표현식(Cron Expression) 또는 Fixed Delay 를 통해 작업 스케줄을 설정할 수 있다.
    • 비동기 처리: 스케줄된 작업을 비동기적으로 실행하여 애플리케이션의 성능을 유지할 수 있다.
    • 유연한 구성: XML 설정 또는 애노테이션을 통해 쉽게 설정할 수 있다.

 

스프링 스케줄러는 주로 정기적인 백업, 이메일 알림, 데이터 동기화 등의 작업을 자동화하는 데 사용된다.

 

 

특징

                                              스프링 배치 (Spring Batch)                                         스프링 스케줄러 (Spring Scheduler)

목적 대량 데이터 일괄 처리 시간 기반 작업 실행
주요 기능 트랜잭션 관리, 재시작 및 중단, 데이터 검증 및 오류 처리 크론 표현식, 고정된 지연, 비동기 처리
사용 사례 금융 데이터 처리, 대량 파일 처리, 시스템 통합 정기 백업, 이메일 알림, 데이터 동기화
구성 방식 주로 XML 설정 또는 Java Config 애노테이션 또는 XML 설정

 

 

 


 

스프링 배치 구조

 

스프링배치 주요 흐름 이미지 출처 (https://terasoluna-batch.github.io/)

 

  1. 스프링 배치 (Spring Batch):
    • 스프링 배치는 대규모 데이터 처리 작업을 자바 기반의 배치 처리 어플리케이션을 만들기 위한 프레임워크
    • 대용량 데이터를 처리하는 경우나 주기적인 업무 일괄 처리 등에 사용
    • 스프링 배치는 배치 작업을 트랜잭션 처리와 함께 관리하고, 실패한 작업을 복구하고 재시작할 수 있는 기능을 제공
    • 잡(Job), 스텝(Step), 리더(Reader), 프로세서(Processor), 라이터(Writer) 등의 개념을 이용하여 배치 처리를 설계하고 실행한다.
    • job은 여러개의 step 으로 구성되고 step은 taskelt(기능) 으로 구성된다.
    • 나는 tasklet으로 구현하였다.!!
  2. 쿼츠 (Quartz):
    • 쿼츠는 자바 기반의 오픈 소스 스케줄링 라이브러리
    • 스케줄링 작업을 관리하고 실행할 수 있는 기능을 제공
    • 주기적으로 실행되어야 하는 작업이나 예약된 작업을 관리하고 실행하는 데 사용
    • 쿼츠는 크론 표현식을 사용하여 실행 스케줄을 설정하고, 다양한 트리거(trigger)를 통해 작업을 실행할 수 있음
    • 분산 환경에서도 사용할 수 있는 확장 가능한 아키텍처를 가지고 있어, 대규모 시스템에서도 유연하게 활용됨
요약하자면, 스프링 배치는 대규모 데이터 처리를 위한 일괄 처리 프레임워크이고, 쿼츠는 예약된 작업을 스케줄링하고 실행하는 데 사용되는 라이브러리 
-> 함께 사용되어 시스템에서 정기적인 작업 처리와 스케줄링을 효과적으로 관리할 수 있음

 

 

 

Job : 전체 배치 프로세스를 캡슐화한 엔티티, 배치 처리 과정을 하나의 단위로 만들어 놓은 객체

 

JobInstance : Job 실행의 단위(job이 각각 실행할 때마다 따로 추적할 수 있도록 매 실행마다 생성되며 특정 job과 식별 가능한 JobParameters에 상응하는 JobInstance는 단 한 개뿐이다.)

 

JobParameters : JobInstance에 전달되는 매개변수 역할, JobInstance를 구분하는 식별자 역할

 

JobExecution : Job에 대한 실행 시도 정보를 담은 객체.

 

Step : 배치 Job의 배치처리를 정의하고 순차적인 단계를 캡슐화 한 도메인 객체. Job은 하나 이상의 Step을 가져야 한다.

 

StepExecution : Step에 대한 실행 시도 정보를 담은 객체.

 

ExecutionContext : 프레임워크에서 유지/관리하는 key-value의 컬렉션. StepExecution객체 또는 JobExecution객체에 속하는 상태를 저장

 

JobExecutionContext : Commit 시점에 저장

 

StepExecutionContext : 실행 사이에 저장

 

JobRepository : 위에 업근된 모든 저장(Persistence)매커니즘을 담당.(JobLauncher, Job, Step 구현체에 CRUD 기능을 제공)

 

JobLauncher : 주어진 JobParameters로 Job을 실행하는 간단한 인터페이스.

 

- Step에서 아이템을 한 번에 하나씩 읽어오는 작업을 추상화한 개념

Tasklet 

   or

Item Reader 

Item Writer : Step에서 배치나 청크 단위로 아이템을 출력하는 작업을 추상화한다.

Item Processor : 아이템을 처리하는 비즈니스 로직을 나타내는 추상화 개념

 


 

https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-5.0-Migration-Guide

 

Spring Batch 5.0 Migration Guide

Spring Batch is a framework for writing batch applications using Java and Spring - spring-projects/spring-batch

github.com

스프링배치5부터는 매인 애플리케이션에 @EnableBatchProcessing 어노테이션을 사용할 필요가 없다.

자세한 내용은 마이그레이션 가이드부터 읽어 보길 권장한다!!

 

 

728x90
728x90

개요

이전 블로그에 지연로딩, 즉시로딩에 대해서 살펴보았다.

JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 된다.
처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했다.
어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠다.

 

 

Join, Fetch Join 차이점 요약

  • 일반 Join
    • Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
    • 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨

 

  • Fetch Join
    • 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
    • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨

 

 

N+1 문제

단순 테이블 조회 쿼리를 하나 생성하였는데, 연관관계에 있는 데이터를 모두 불러오기 위해서 select 쿼리가 테이블의 레코드 수(혹은 호출한 데이터 수) 만큼 더 생성되는 문제를 의미한다.

 

즉시로딩은 최초 테이블 데이터를 모두 불러온 후 바로 연관관계가 있는 엔티티를 조회하는 방식을,

지연로딩은 최초 테이블 데이터를 모두 불러온 후 필요한 시점에 연관관계가 있는 엔티티를 조회하는 방식을 채택한다.

 

즉시로딩은 워딩 그대로 조회 시점에 연관관계에 있는 엔티티가 모두 조회되어 N+1 문제가 생긴다는 것을 직관적으로 알 수 있지만 지연로딩은 얼핏보면 로딩 시점에 쿼리가 더 나가지 않아 N+1 문제가 발생하지 않을 것으로 보인다. 하지만 지연로딩 N+1 문제를 피해갈 수는 없는데, 그 이유는 연관관계에 있는 엔티티가 조회 시점에 영속화되지 않았기 때문이다.

 

일반적으로 JPA는 JPQL 로 생성된 쿼리문을 데이터베이스와 connection 을 만든 후에 전송하고, 이에 대한 결과 데이터를 전달받아 실제 엔티티 객체를 생성하고 이를 영속화 한다.

 

 

 

예시

@Entity
class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    
    String title;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    List<Comment> cpmments = ArrayList()
}

@Entity
class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    
    String content;

    @ManyToOne(fetch = FetchType.LAZY)
    Post post;
}
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public void getMockposts() {
        postRepository.findAll().forEach(post -> {
            post.getpPostComments().forEach(cpmments ->
                System.out.println(cpmments.getContent())
            );
        });
    }
}

위 코드에서 N+1 문제가 발생할 수 있다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회한다. 이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법이다. 즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있다.

SELECT * FROM post;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;

 

 

해결 방법

1. Fetch Join (@Query 어노테이션 사용)

  • Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
  • Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.
  • 연관된 엔티티를 쿼리 시 함께 조회하는 방법이다.
  • SQL 쿼리에 직접 Join fetch를 명시한다.
  • 조회의 주체가 되는 엔티티 외에 Fetch Join이 걸린 연관 엔티티도 같이 영속화 시켜준다.
  •  
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
}
  •  "JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회한다.
  • join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.

 

2. Entity Graph

  • EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있다.
  • JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해준다.
  • 지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회된다.
  • EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작한다
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

@EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
@Query("SELECT p FROM post p")
List<Post> findAllPostsWithComments();

}

 

 

Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제

Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
 

 

 


1. JPQL에 DISTINCT 사용하기


JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.

@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

    @Query("SELECT DISTINCT p from Post p JOIN FETCH p.comments")
	List<Post> findAllPostsWithComments();
    
    @EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
	@Query("SELECT DISTINCT p FROM post p")
	List<Post> findAllPostsWithComments();
}


 

2. 연관관계의 필드 타입에 Set 사용하기

  • Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않는다.
  • 이러한 경우 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있다.
@Getter
@NoArgsConstructor
@Entity(name = "posts")
public class post {

    @OneToMany(mappedBy = "post")
    private Set<Comment> comments = new LinkedHashSet<>();

}

 

 

728x90

+ Recent posts