처리이력 기능 확장 : 이력 테이블에서 변경 전/후 값 출력

기능 설명 및 변경 요구사항

  • 처리 이력 기능이란?
    • 도메인 객체의 변경 이력을 저장하는 기능
    • “누가, 언제, 무엇을” 변경 했는지를 기록하고, 이를 쉽게 조회할 수 있는 기능
  • 요구사항 변경 사항 : 이력 테이블에서 변경 전/후 값 함께 출력하기
    • 기존 : 사용자가 어떤 필드의 값을 변경했는지, 즉 변경자와 변경된 필드 및 새로운 값을 기록하는 방식으로 처리됨
    • 기존 시스템에서는 변경된 정보만 저장하고 출력했지만, 새롭게 요구된 사항은 이전 값도 함께 출력해야 함
    • 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) 데이터 예시

image.png

이슈(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) 로직 흐름

  • 이력 데이터 조회
    • targetNametargetId를 기준으로, 해당 이력 데이터를 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) 정렬 활용 방안

  • changedFieldcreatedAt을 기준으로 그룹화 및 정렬시, 같은 필드와 관련된 변경사항 찾기 쉬워짐
  • 시간 복잡도 : 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 : 키가 존재하지 않을 때 계산할 값을 반환하는 함수
    • 동작 방식
      • 주어진 keyMap에 존재하는지 확인
      • 만약 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 기준으로 데이터를 정렬, 시간 순으로 이력 데이터를 확인

(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;