이력 조회시 SQL 윈도우 함수 적용
처리이력 기능 확장 : 이력 테이블에서 변경 전/후 값 출력
기능 설명 및 변경 요구사항
- 처리 이력 기능이란?
- 도메인 객체의 변경 이력을 저장하는 기능
- “누가, 언제, 무엇을” 변경 했는지를 기록하고, 이를 쉽게 조회할 수 있는 기능
- 요구사항 변경 사항 : 이력 테이블에서 변경 전/후 값 함께 출력하기
- 기존 : 사용자가 어떤 필드의 값을 변경했는지, 즉 변경자와 변경된 필드 및 새로운 값을 기록하는 방식으로 처리됨
- 기존 시스템에서는 변경된 정보만 저장하고 출력했지만, 새롭게 요구된 사항은 이전 값도 함께 출력해야 함
- ex.이슈의 상태가
Open에서InProgress로 변경되었을 때,Open이라는 이전 값과InProgress라는 변경 후 값을 모두 보여주어야 함
요구사항 변경 : 테이블 구조의 제약
새로운 요구사항을 반영하려면 변경 전 값을 함께 저장해야 함
가장 쉬운 방법은 이력 테이블에 이전 값 컬럼을 추가하는 것이지만 테이블 스키마 변경 불가
테이블 구조 변경 없이 이전 값도 함께 출력 가능하도록 문제 해결 방법 필요
처리 이력(ActionHistory) 설계 구조
(1) 도메인
@Getter
@Builder
public class ActionHistory {
private Long id;
private String targetName; // 히스토리 대상 엔티티 이름
private Long targetId; // 대상 엔티티의 id
private ChangedFieldType changedField; // 변경된 필드 이름
private String changedValueId; // 변경된 필드의 새로운 값의 id
private String changedValue; // 변경된 필드의 새로운 값
private Long modifierId; // 변경을 수행한 사용자 id
private LocalDateTime modifiedDate; // 변경 이뤄진 시간
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
(2) 데이터 예시

이슈(id: 123)의 상태가 Open에서 InProgress로 변경된 경우
targetName: 변경된 엔티티가 이슈이므로 “ISSUE”가 저장됨
targetId: 변경된 Issue(엔티티)의 id인 “123”이 저장됨
changedField: 엔티티의 변경된 필드명 저장
예시에서는 이슈 엔티티의 상태 필드가 변경되었으므로 “STATUS” 저장됨
changedValueId: 변경된 필드가 어떤 값으로 변경되었는지 저장
예시에서는 Status 엔티티에서 InProgress에 해당하는 StatusId 값인 “2”가 저장됨
changedValue: 변경된 실제 값도 저장
id만 저장할 경우 변경할 당시의 실제 값을 알 수 없게 될 수 있기 때문에 실제 값도 함께 저장함
ex. 상태명을 OPEN 에서 OPEN2로 변경하면 변경할 당시의 상태명을 알 수 없게 됨
modifierId: 해당 이력을 발생시킨 사용자의 id 저장
해결 방안 1 : 애플리케이션 로직으로 처리
어플리케이션 레벨에서 직접 이전 값(변경 전 값)을 처리하는 방법
이력 테이블에 저장된 데이터만을 사용하여 이전 값(beforeChangedValue)과 현재 값(changedValue)을 함께 출력할 수 있도록 해야 함
(1) 로직 흐름
- 이력 데이터 조회
targetName과targetId를 기준으로, 해당 이력 데이터를createdAt순으로 정렬하여 가져옴- 정렬을 통해 가장 먼저 발생한 이력부터 마지막 이력까지 순차적으로 나열되면, 그 이전에 어떤 변경이 있었는지 확인 가능
- 직전 값 추적
- 데이터베이스에서 가져온 이력 데이터를 애플리케이션 내에서 한 줄씩 순회함
- 각 이력 레코드에서 현재값(changedValue)는 바로 알 수 있지만, 직전 값을 확인하려면 이력 레코드에서 동일한 changedField 중에서 바로 이전 createdAt에 해당하는 changedValue를 찾아야함
- 한 줄씩 데이터를 순회하면서 직전 값을 찾아
beforeChangedValue로 설정
- 이력 데이터 출력
beforeChangedValue는 이전 값,changedValue는 현재 값에 해당함- 각 이력에 대해 변경 전/후 값을 HistoryDto에 담아 결과 리스트에 createdAt 순서로 추가함
- 변경 전/후 값을 함께 출력 가능해짐
(2) 예시
- 이력 데이터 조회 예시
- 특정
targetName(ISSUE)과targetId(123)를 기준으로 시간 순서대로 정렬해서 가져옴
2023-01-01 10:00:00: [ 상태 ] “Open”
2023-01-01 10:00:00: [ 핸들러 ] “John Doe”
2023-01-02 12:00:00: [ 상태 ] “In Progress”
2023-01-03 15:00:00: [ 상태 ] “Closed”
2023-01-03 15:00:00: [ 핸들러 ] “Jane Smith” - 특정
- 직전 값 추척 예시
[ 상태 ] "Open"기준으로 이전 값 없음[ 핸들러 ] "John Doe"기준으로 이전 값 없음[ 상태 ] "In Progress"기준으로 동일한 changedField(상태)인 값 중에 가장 가까운 createdAt은[ 상태 ] "Open"에 해당하므로 beforeChagnedValue는 “Open”
- 예시 결과
2023-01-01 10:00:00: [ 상태 ] 변경 전 : null → 변경 후 : “Open”
2023-01-01 10:00:00: [ 핸들러 ] 변경 전 : null → 변경 후 : “John Doe”
2023-01-02 12:00:00: [ 상태 ] 변경 전 : “Open” → 변경 후 : “In Progress”
2023-01-03 15:00:00: [ 상태 ] 변경 전 : “In Progress” → 변경 후 : “Closed”
2023-01-03 15:00:00: [ 핸들러 ] 변경 전 : “John Doe” → 변경 후 : “Jane Smith”
해결 방안 2 : 정렬을 활용하여 직전값 추적 방안 개선
(1) 기존 방식 : 이중 루프 방식
- 모든 이력 데이터에 대해 직전 값을 찾기 위해서는 이중 for문을 사용함
- 시간 복잡도 : O(N^2)
(2) 정렬 활용 방안
changedField와createdAt을 기준으로 그룹화 및 정렬시, 같은 필드와 관련된 변경사항 찾기 쉬워짐- 시간 복잡도 : O(N log N)
(3) 로직 설명 및 예시
-
처리 이력 조회
getActionHistoryByTargetNameAndTargetId호출- DB에서 targetName과 targetId에 해당하는 처리 이력 데이터를 가져옴
List<ChangeEntry> changes = getActionHistoryByTargetNameAndTargetId(targetName, targetId); -
필드별로 데이터 항목을 그룹화 및 시간 정렬 : O(N logN)
- 각 변경 항목을 필드별로 그룹화하기 위해
Map<ChangedFieldType, List<ChangeEntry>>를 생성 computeIfAbsent사용- 동일한 필드(ex: 상태, 핸들러 등)에서의 변경 사항만을 그룹으로 묶음
- 이때 해당 필드가 Map에 이미 존재하면 리스트에 값을 추가
- 존재하지 않으면 새로운 리스트를 생성하여 추가함
- 시간 순서대로 정렬
- 각 필드별로 그룹화된 데이터는 변경된 시점에 따라 정렬해야 함
Comparator.comparing(ChangeEntry::getCreatedAt)을 사용
- 각 변경 항목을 필드별로 그룹화하기 위해
Map<ChangedFieldType, List<ChangeEntry>> groupedByField = new HashMap<>();
for (ChangeEntry entry : changes) {
groupedByField.computeIfAbsent(entry.getChangedField(), k -> new ArrayList<>()).add(entry);
}
for (List<ChangeEntry> entries : groupedByField.values()) {
entries.sort(Comparator.comparing(ChangeEntry::getCreatedAt));
}
- 변경 전 값 추적 : O(N)
- 각 필드별로 정렬된 데이터에서 변경 전 값을 추적
previousEntry변수는 이전 값을 저장하며,IssueHistoryResponse에 전달됨- 이중 루프이지만 필드별 그룹화가 미리 되어 있기 때문에, 각 필드에서 변경 전 값을 추적하는 시간 복잡도는 O(N)
List<IssueHistoryResponse> issueHistoryResponses = new ArrayList<>();
for (Map.Entry<String, List<ChangeEntry>> fieldEntry : groupedByField.entrySet()) {
List<ChangeEntry> fieldEntries = fieldEntry.getValue();
ChangeEntry previousEntry = null;
for (ChangeEntry entry : fieldEntries) {
// 이전 값 추적 및 IssueHistoryResponse 변환
String previousValue = (previousEntry != null) ? previousEntry.getChangedValue() : null;
issueHistoryResponses.add(IssueHistoryResponse.fromChangeEntry(entry, previousValue));
previousEntry = entry;
}
}
More ⬆︎ computeIfAbsent 메서드 동작
- 문법
K key: Map에서 검색할 키Function<? super K, ? extends V> mappingFunction: 키가 존재하지 않을 때 계산할 값을 반환하는 함수- 동작 방식
- 주어진
key가Map에 존재하는지 확인 - 만약
key가 존재하면,Map에서 해당key에 대한 값을 반환 - 만약
key가 존재하지 않으면,mappingFunction을 사용하여 값을 계산하고, 이 값을Map에 추가한 후 반환
- 주어진
V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction);
(4) 한계 및 성능 개선 방안
IDEA 도출
애플리케이션 레벨에서 데이터를 처리 방식의 단점
SQL 쿼리를 활용하여 데이터베이스 레벨에서 문제를 해결하는 방식이 더 효율적일 수 있음
해결 방안 3 : SQL 윈도우 함수 활용
SQL 윈도우 함수를 활용한 해결책
SQL 윈도우 함수를 사용하여 이전 값과 현재 값을 동시에 조회하는 방법을 적용
성능 향상
(1) SQL 쿼리 설명
- 쿼리 설명
- LAG 함수 :
LAG(h.changed_value)는 각 이력의 이전 값을 가져옴 - LEFT JOIN 사용
- 사용자 정보는 처리 이력 당시 정보가 아닌 최신정보 조회해야 함
- 이전 값과 현재 값이 사용자 엔티티와 연결된 경우,
LEFT JOIN을 사용하여 사용자 이름 매핑
- CASE 문
status필드인 경우에는 상태 값의 이전과 현재 상태를 출력handler인 경우에는 이전과 현재 담당자 이름을 출력
- ORDER BY :
modified_date기준으로 데이터를 정렬, 시간 순으로 이력 데이터를 확인
- LAG 함수 :
(2) SQL 쿼리 예시
SELECT history.id,
history.changed_value_id,
history.modifier_id,
history.modifier_name,
history.changed_field,
CASE
WHEN history.changed_field = 'status' THEN history.previous_value
WHEN history.changed_field = 'handler' THEN prev_user.name
END AS previous_value,
CASE
WHEN history.changed_field = 'status' THEN history.changed_value
WHEN history.changed_field = 'handler' THEN curr_user.name
END AS changed_value,
history.modified_date
FROM (SELECT h.id,
h.changed_value_id,
u.id AS modifier_id,
u.name AS modifier_name,
h.changed_field,
LAG(h.changed_value) OVER (PARTITION BY h.target_id, h.modifier_id, h.changed_field
ORDER BY h.modified_date) AS previous_value, h.changed_value,
h.modified_date
FROM action_history h
LEFT JOIN users u ON h.modifier_id = u.id
WHERE h.target_id = #{targetId}
AND h.target_name = #{targetName}) history
LEFT JOIN users prev_user
ON history.changed_field = 'handler' AND TO_CHAR(history.previous_value) = TO_CHAR(prev_user.id)
LEFT JOIN users curr_user
ON history.changed_field = 'handler' AND TO_CHAR(history.changed_value) = TO_CHAR(curr_user.id)
ORDER BY history.modified_date ASC;
More ⬆︎ 윈도우 함수 LAG, LEAD 함수 사용법
- 문법
LAG / LEAD 함수: 이전 행 / 다음 행의 값을 리턴expr: 대상 컬럼명offset(생략 가능) : 값을 가져올 행의 위치 [ 기본값 : 1 ]default(생략 가능) : 값이 없을 경우 기본값partition_by_clause(생략 가능): 그룹 컬럼명order_by_claose: 정렬 컬럼명
LAG(expr [,offset] [,default]) OVER([partition_by_clause] order_by_clause)
LEAD(expr [,offset] [,default]) OVER([partition_by_clause] order_by_clause)
- 예시
SELECT id, name, sal,
LAG(sal) OVER(ORDER BY id) AS sal_prev, -- 직전 연봉
LEAD(sal) OVER(ORDER BY id) AS sal_next -- 이후 연봉
FROM emp;