평소처럼 평화롭게 회사를 다니고 있던 어느 날, Discord에 올라온 메시지 하나가 눈에 들어왔다.
서비스에 권한 관련 데이터가 자꾸 사라져요!
해당 이슈는 사실 몇 달 전부터 계속해서 발생하고 있던 문제였다.
하지만 B2B라는 서비스 특성 때문인지, 장기간 꾸준히 발생한 장애임에도 불구하고 누구도 크게 관심을 두지 않았다.
그러던 중 이 메시지가 트리거가 되어, 비로소 문제는 팀 내에서 주목받기 시작했다.
이후 원인을 추적하려 했지만 쉽게 잡히지 않았다.
당시 나는 문제와 직접 연관된 프로젝트가 아니라 다른 일을 집중적으로 하고 있었기에 깊게 관여하지 않고 있었다.
Discord 메시지가 올라온 지 약 3일 정도 지나, 회의 자리에서도 해당 이슈가 다시 논의되었다.
최근 기술 서적을 여러 권 읽었던 탓인지, 문득 “데이터가 삭제되거나 수정될 때 로그를 남겨야겠다”는 생각이 들었다.
그리고 이왕이면 애플리케이션이 아니라 문제 발생 지점과 가장 가까운 DB 레벨에서 로깅하는 게 맞겠다는 결론에 도달했다.
이때 가장 먼저 떠오른 기술이 CDC(Change Data Capture)였다.
CDC는 데이터 변경 이벤트를 감지해 이를 기반으로 후속 액션을 실행하는 구조다.
미래에는 삭제·수정·생성 같은 민감한 변화를 추적할 때 CDC를 사용할 수도 있겠지만,
현재로써 단순히 특정 테이블에서 변경 로그만 남기기에는 오버스펙이었다.
그래서 두 번째로 떠올린 방법이 Trigger였다.
ChatGPT와 몇 차례 티키타카를 하며 트리거 함수와 로그 테이블 스펙을 구성했고, 빠르게 프로토타입을 만들 수 있었다.
트리거 예시는 다음과 같다:
-- 1️⃣ 감사 로그 테이블 생성
CREATE TABLE seavantage.audit_log
(
id bigserial primary key,
action_type varchar(10) not null, -- 'UPDATE' or 'DELETE'
changed_at timestamp default now(),
changed_by text default current_user,
... -- 컬럼들
old_values jsonb, -- 변경 전 데이터
new_values jsonb, -- 변경 후 데이터 (UPDATE 시에만)
executed_query text -- 수행된 쿼리
);
-- 2️⃣ 트리거 함수 생성
CREATE OR REPLACE FUNCTION trg_audit()
RETURNS trigger AS
$$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (action_type,
... -- 컬럼들
old_values,
executed_query)
VALUES ('DELETE',
... -- 컬럼들
to_jsonb(OLD),
current_query());
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (action_type,
... -- 컬럼들
old_values,
new_values,
executed_query)
VALUES ('UPDATE',
... -- 컬럼들
to_jsonb(OLD),
to_jsonb(NEW),
current_query());
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- 3️⃣ 트리거 생성
DROP TRIGGER IF EXISTS trg_audit
ON 감시할_테이블;
CREATE TRIGGER trg_audit
AFTER UPDATE OR DELETE
ON 감시할 테이블
FOR EACH ROW
EXECUTE FUNCTION trg_audit();
트리거 이름과 컬럼 등은 실제와 다르게 일부 수정됐지만, 전체 구조는 동일하다.
이 아이디어를 팀장님께 공유했고, 운영환경에 바로 적용해두었다.
그리고 마치 강태공이 대어를 기다리듯… 조용히 데이터가 사라지기를(?) 기다렸다.
점심을 먹고 돌아온 뒤, 혹시나 하는 마음에 로깅 테이블을 조회해보았다.

그리고 마침내 장애가 재현되었다!
그리고 더 놀라운 사실은, 전혀 관련 없어 보이는 데이터들이 서로 영향을 주는 형태로 변경되고 있었다는 점이었다.
데이터 변경 시각이 명확해졌고, old → new 형태로 추적이 가능해지면서
우리가 마주한 상황이 꽤나 비정상적이라는 것을 확신할 수 있었다.
권한이 업데이트된 시점은 이상하게도 워크스페이스 생성 시점과 정확히 일치했다.
하지만 논리적으로 이상했다.
워크스페이스가 새로 생성된다면
권한은 “업데이트”가 아니라 “새로 생성”되는 것이 정상 아닌가?
이는 마치 다른 워크스페이스의 권한을 복사해 덮어씌우고 있는 듯한 느낌이었다.
그래서 코드를 면밀히 확인하던 중, 다음과 같은 Enum 코드를 발견했다.
public enum DefaultShipType {
CONTAINER(List.of(...)),
BULK(List.of(...)),
CARGO(List.of(...)),
CHEMICAL_TANKER(List.of(...)),
LNG(List.of(...)),
LPG(List.of(...)),
PCC(List.of(...)),
PRODUCT_TANKER(List.of(...)),
TANKER(List.of(...)),
RORO(List.of(...));
private final List<권한> 권한s;
DefaultShipType(List<String> shipTypeSizeList) {
this.권한s = shipTypeSizeList.stream()
.map(shipTypeSize ->
권한.builder()
.shipTypeSize(ShipTypeSize.valueOf(shipTypeSize))
.shipType(ShipType.from(this.name()))
.build())
.toList();
}
public List<권한> addWorkspaceId(UUID companyId) {
return 권한s.stream()
.map(shipType -> shipType.addWorkspaceId(companyId))
.toList();
}
}
이 Enum은 “워크스페이스 생성 시 기본적으로 등록되어야 할 권한 목록”을 내부에 들고 있는 구조이다.
그리고 권한 객체는 JPA Entity다.
addWorkspaceId()는 내부 권한 리스트에 workspaceId만 바꿔 끼워 반환하는 구조다.
문제는 여기서 시작된다.
Enum은 애플리케이션 시작 시 단 한 번 초기화된다.
즉, 권한s 리스트는 서버가 떠 있는 동안 계속 유지되며,
workspaceId만 다르게 할당될 뿐 객체 자체는 계속 재사용된다.
그러나 JPA는…
즉, 한 번 워크스페이스가 생성되고 나서
기본 권한이 DB에 저장되면, Enum 내부의 권한s 리스트에도 ID가 박힌 Entity들이 남아 있게 된다.
그 상태에서 또 다른 워크스페이스가 생성되면?
→ JPA는 이것을 “새로운 권한 생성”이 아니라
→ Enum이 들고 있던 이미 존재하는 Entity를 업데이트하는 작업으로 처리해버린다.
그 결과:
바로 몇 달 동안 우리를 괴롭힌 그 문제였다.
다행히 해결은 정말 간단했다.
우리 회사는 클린 아키텍처를 차용하고 있어
Domain과 Entity를 명확히 분리하고 있다.
따라서 Enum에서 관리하는 권한을 Entity가 아닌 Domain 객체로 변경하도록 구조를 바꿨다.
이렇게 되면 JPA가 Enum 내부 객체에 ID를 부여하는 위험성이 사라지고, 문제는 즉시 해결된다.
문서만 보면 이해가 어려울 수 있어 정리 모델을 간단히 이미지로 만들었다.
데이터 변경 추적 플로우

장애 발생 플로우

위 플로우로 인해 발생하는 문제

전체 흐름을 보면 문제의 원인이 한눈에 들어올 것이다.
운영 중 장애는 언제나 스트레스도 크고 위험한 상황이지만,
그만큼 해결했을 때의 도파민도 엄청나다.
회사에서 이런 문제를 해결할 때마다
“아, 개발자라는 일이 참 재밌다”는 생각이 든다.
앞으로도 계속 다양한 문제를 해결하며 좋은 프로덕트를 만들고 싶다.
항상 이렇게 문제 해결하며 성장해가는 모습이 정말 임팩트 주니어네요!