CoreERP Audit Log 기능 구현 정리

최병현·2026년 3월 16일

coreerp project

목록 보기
34/44

이번 작업에서는 CoreERP 프로젝트에 감사 로그(Audit Log) 기능을 추가했다. 이 기능의 목적은 ERP 내부에서 발생하는 주요 작업 이력을 남기고, 이후 관리자 화면에서 검색·조회할 수 있도록 만드는 것이다. 단순히 로그를 남기는 수준이 아니라, 누가 어떤 작업을 했는지, 어떤 대상에 대해 작업했는지, 언제 실행되었는지, 어떤 상세 데이터가 포함되었는지까지 추적할 수 있도록 설계했다.

이번 단계에서 구현한 범위는 크게 두 영역으로 나뉜다.

  • Backend: Audit Log 저장 구조, 서비스 삽입, 조회 API, 대상 이름 표시 로직
  • Frontend: Audit Log 조회 페이지, 필터, 테이블, 상세 모달, 표시 포맷 정리

1. 이번 작업의 핵심 목표

ERP는 일반 CRUD 프로젝트와 다르게 데이터 변경 이력이 매우 중요하다. 특히 발주, 입고, 출고, 재고 조정, 사용자 권한 변경 같은 기능은 실제 운영 기록으로 이어지기 때문에 “누가 무엇을 했는가”를 남겨야 한다. 이번 작업에서는 CoreERP에 다음과 같은 감사 로그 흐름을 추가했다.

  • 품목 생성 / 수정
  • 거래처 생성 / 수정
  • 발주 생성 / 취소
  • 입고 등록
  • 출고 등록
  • 창고 이동
  • 재고 조정
  • 사용자 생성 / 권한 변경 / 상태 변경 / 활성화 변경
  • 로그인 / 로그아웃

즉, 실제 서비스 레이어에서 상태가 바뀌는 모든 핵심 작업에 Audit Log를 연결하는 것이 이번 단계의 핵심이었다.


2. Backend 관점에서의 설계 의도

이 기능은 특정 도메인 하나에 종속되는 기능이 아니기 때문에 공통 관심사(Cross Cutting Concern)로 보는 것이 맞다. 따라서 item, purchaseOrder, inbound 같은 개별 feature 내부에 흩어지게 두지 않고 audit 패키지로 분리했다.

패키지 구조는 아래와 같이 잡았다.

com.coreerp.audit
├── controller
│   └── AuditLogController
├── domain
│   ├── AuditAction
│   ├── AuditEntityType
│   └── AuditLog
├── dto
│   └── AuditLogResponse
├── repository
│   └── AuditLogRepository
├── service
│   ├── AuditLogService
│   └── AuditLogQueryService
└── support
    ├── AuditContext
    └── CurrentAuditActorResolver

이 구조의 장점은 매우 명확하다. 저장과 조회가 분리되고, SecurityContext에서 현재 사용자를 꺼내는 책임도 분리되며, 개별 도메인 서비스는 단순히 auditLogService.log(...)만 호출하면 된다. 즉 기능 추가와 유지보수 모두 쉬워진다.


3. AuditLog 엔티티 설계

감사 로그 테이블은 단순히 action 하나만 저장하는 것이 아니라 ERP 운영 추적에 필요한 최소 정보를 모두 담도록 설계했다.

  • userId: 작업한 사용자 ID
  • username: 작업한 사용자 로그인 ID 또는 이름 표시용 값
  • action: CREATE, UPDATE, CANCEL, LOGIN 같은 작업 종류
  • entityType: ITEM, USER, PURCHASE_ORDER 같은 대상 종류
  • entityId: 실제 대상 엔티티의 PK
  • description: 사람이 바로 읽을 수 있는 설명
  • detailJson: 변경 상세 데이터
  • ipAddress: 요청 IP
  • userAgent: 브라우저 정보
  • createdAt: 생성 시각

이렇게 해두면 이후 운영 화면에서 단순 목록 확인뿐 아니라, 특정 사용자 추적, 특정 엔티티 변경 내역 조회, 기간 검색, 상세 JSON 확인까지 가능해진다.

@Entity
@Table(
        name = "audit_logs",
        indexes = {
                @Index(name = "idx_audit_logs_created_at", columnList = "created_at"),
                @Index(name = "idx_audit_logs_user_id", columnList = "user_id"),
                @Index(name = "idx_audit_logs_action", columnList = "action"),
                @Index(name = "idx_audit_logs_entity", columnList = "entity_type, entity_id")
        }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AuditLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "username", length = 100)
    private String username;

    @Enumerated(EnumType.STRING)
    @Column(name = "action", nullable = false, length = 30)
    private AuditAction action;

    @Enumerated(EnumType.STRING)
    @Column(name = "entity_type", nullable = false, length = 50)
    private AuditEntityType entityType;

    @Column(name = "entity_id")
    private Long entityId;

    @Column(name = "description", nullable = false, length = 500)
    private String description;

    @Lob
    @Column(name = "detail_json")
    private String detailJson;

    @Column(name = "ip_address", length = 45)
    private String ipAddress;

    @Column(name = "user_agent", length = 255)
    private String userAgent;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;
}

4. 현재 로그인 사용자 추출 구조

CoreERP 인증 구조는 JWT 기반이며, JwtAuthenticationFilter에서 Authentication을 직접 구성하고 있다. 여기서 principal에는 loginId를 넣고, details에는 userId를 넣는 구조로 정리했다. 이 결정이 중요했던 이유는 이후 감사 로그에서 작업자 식별이 매우 쉬워지기 때문이다.

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
        user.getLoginId(),
        null,
        List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))
);

authentication.setDetails(user.getId());
SecurityContextHolder.getContext().setAuthentication(authentication);

이렇게 잡아두면 Audit Log에서 현재 사용자 정보를 가져올 때 별도의 UserDetails 커스텀 객체 없이도 충분하다. CurrentAuditActorResolver는 SecurityContext에서 principal과 details를 꺼내고, HttpServletRequest에서 IP와 User-Agent를 추출해 AuditContext로 반환한다.

@Component
@RequiredArgsConstructor
public class CurrentAuditActorResolver {

    private final HttpServletRequest request;

    public AuditContext resolve() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Long userId = null;
        String username = "anonymous";

        if (authentication != null && authentication.isAuthenticated()) {
            Object principal = authentication.getPrincipal();
            if (principal instanceof String str && !"anonymousUser".equals(str)) {
                username = str;
            }

            Object details = authentication.getDetails();
            if (details instanceof Long id) {
                userId = id;
            }
        }

        String ipAddress = request.getRemoteAddr();
        String userAgent = request.getHeader("User-Agent");

        return new AuditContext(userId, username, ipAddress, userAgent);
    }
}

5. AuditLogService 구현

AuditLogService는 저장 전용 공통 서비스다. 각 도메인 서비스에서 직접 AuditLog 엔티티를 만들지 않고, auditLogService.log(...) 형태로 호출만 하도록 단순화했다. 이렇게 하면 서비스 코드의 책임이 명확해지고, JSON 직렬화 같은 공통 로직도 한 곳에 모을 수 있다.

@Service
@RequiredArgsConstructor
@Transactional
public class AuditLogService {

    private final AuditLogRepository auditLogRepository;
    private final CurrentAuditActorResolver currentAuditActorResolver;
    private final ObjectMapper objectMapper;

    public void log(
            AuditAction action,
            AuditEntityType entityType,
            Long entityId,
            String description
    ) {
        save(action, entityType, entityId, description, null);
    }

    public void log(
            AuditAction action,
            AuditEntityType entityType,
            Long entityId,
            String description,
            Map<String, Object> detail
    ) {
        save(action, entityType, entityId, description, toJson(detail));
    }

    private void save(
            AuditAction action,
            AuditEntityType entityType,
            Long entityId,
            String description,
            String detailJson
    ) {
        AuditContext actor = currentAuditActorResolver.resolve();

        AuditLog auditLog = AuditLog.builder()
                .userId(actor.userId())
                .username(actor.username())
                .action(action)
                .entityType(entityType)
                .entityId(entityId)
                .description(description)
                .detailJson(detailJson)
                .ipAddress(actor.ipAddress())
                .userAgent(actor.userAgent())
                .createdAt(LocalDateTime.now())
                .build();

        auditLogRepository.save(auditLog);
    }

    private String toJson(Map<String, Object> detail) {
        if (detail == null || detail.isEmpty()) {
            return null;
        }

        try {
            return objectMapper.writeValueAsString(detail);
        } catch (JsonProcessingException e) {
            return "{\"error\":\"audit_detail_json_serialization_failed\"}";
        }
    }
}

이 서비스의 핵심은 복잡한 비즈니스 로직을 넣지 않는 것이다. AuditLogService는 기록만 담당하고, 실제 어떤 값을 detail에 담을지는 각 업무 서비스가 결정하도록 했다.


6. 실제 업무 서비스에 로그 삽입

Audit Log에서 가장 중요한 부분은 QueryService가 아니라 실제 상태 변경이 발생하는 Service에 로그를 넣는 것이다. 조회는 상태를 바꾸지 않기 때문에 감사 대상이 아니다. 따라서 create, update, confirm, cancel, changeRole 같은 메서드 안에 삽입했다.

이번에 로그를 넣은 주요 서비스는 아래와 같다.

  • AuthService
  • ItemService
  • PurchaseOrderService
  • InboundService
  • OutboundService
  • WarehouseTransferService
  • StockService
  • UserService
  • VendorService
  • WarehouseService

예를 들어 품목 생성은 저장이 끝난 직후 아래처럼 기록한다.

auditLogService.log(
        AuditAction.CREATE,
        AuditEntityType.ITEM,
        saved.getItemId(),
        "품목 생성",
        detail
);

발주 취소는 취소 상태 변경이 완료된 직후 아래처럼 기록한다.

auditLogService.log(
        AuditAction.CANCEL,
        AuditEntityType.PURCHASE_ORDER,
        po.getPoId(),
        "발주 취소",
        detail
);

입고 등록, 출고 등록, 창고 이동, 재고 조정, 사용자 권한 변경 역시 모두 같은 원리로 처리했다. 즉 “DB 상태가 실제로 바뀐 직후”에 기록하도록 통일했다.


7. AuditLog 조회 API 구현

감사 로그는 저장만 되면 의미가 없다. 실제 운영자가 검색하고 확인할 수 있어야 하므로 조회 API를 따로 만들었다. Controller는 단순히 요청 파라미터를 받아 QueryService로 전달하는 구조다.

@RestController
@RequestMapping("/api/audit-logs")
@RequiredArgsConstructor
public class AuditLogController {

    private final AuditLogQueryService auditLogQueryService;

    @GetMapping
    public Page<AuditLogResponse> search(
            @RequestParam(required = false) LocalDate from,
            @RequestParam(required = false) LocalDate to,
            @RequestParam(required = false) String action,
            @RequestParam(required = false) String entityType,
            @RequestParam(required = false) Long userId,
            @RequestParam(required = false) String keyword,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size
    ) {
        Pageable pageable = PageRequest.of(page, size);

        return auditLogQueryService.search(
                from,
                to,
                action,
                entityType,
                userId,
                keyword,
                pageable
        );
    }
}

검색 필터는 다음 기준으로 구현했다.

  • 기간 from / to
  • action
  • entityType
  • userId
  • keyword

keyword는 description만 검색하는 것이 아니라 username, detailJson까지 함께 검색하도록 확장했다. 이렇게 해두면 운영자가 설명, 로그인 ID, 상세 JSON 일부로도 로그를 찾을 수 있다.


8. 대상 표시명(entityDisplayName) 문제와 해결

처음에는 프론트에서 대상 ID 컬럼에 숫자만 표시했다. 예를 들어 entityType이 USER이고 entityId가 1이면 화면에는 단순히 1만 보였다. 하지만 실제 운영 화면에서는 이것만으로는 의미가 약하다. 그래서 대상 이름까지 함께 보여주도록 응답을 확장했다.

예를 들어 아래와 같은 형태를 목표로 했다.

  • 김철수 (1)
  • 서울전자상사 [VND-0001] (7)
  • PO-20260316-AB12CD (22)

이를 위해 AuditLogResponse에 entityDisplayName 필드를 추가했고, AuditLogQueryService에서 entityType과 entityId에 따라 실제 표시명을 조회하도록 했다.

public record AuditLogResponse(
        Long id,
        Long userId,
        String username,
        String action,
        String entityType,
        Long entityId,
        String entityDisplayName,
        String description,
        String detailJson,
        String ipAddress,
        String userAgent,
        LocalDateTime createdAt
) {
}

private String resolveEntityDisplayName(AuditEntityType entityType, Long entityId) {
    if (entityType == null || entityId == null) {
        return null;
    }

    return switch (entityType) {
        case ITEM -> itemRepository.findById(entityId)
                .map(item -> item.getItemName() + " [" + item.getItemCode() + "]")
                .orElse(null);

        case VENDOR -> vendorRepository.findById(entityId)
                .map(vendor -> vendor.getVendorName() + " [" + vendor.getVendorCode() + "]")
                .orElse(null);

        case PURCHASE_ORDER -> purchaseOrderRepository.findById(entityId)
                .map(po -> po.getPoNo())
                .orElse(null);

        case INBOUND -> inboundRepository.findById(entityId)
                .map(inbound -> inbound.getInboundNo())
                .orElse(null);

        case OUTBOUND -> outboundRepository.findById(entityId)
                .map(outbound -> outbound.getOutboundNo())
                .orElse(null);

        case WAREHOUSE -> warehouseRepository.findById(entityId)
                .map(warehouse -> warehouse.getWarehouseName() + " [" + warehouse.getWarehouseCode() + "]")
                .orElse(null);

        case TRANSFER -> warehouseTransferRepository.findById(entityId)
                .map(transfer -> transfer.getTransferNo())
                .orElse(null);

        case USER -> userRepository.findById(entityId)
                .map(user -> user.getName())
                .orElse(null);

        case AUTH -> userRepository.findById(entityId)
                .map(user -> user.getName())
                .orElse("인증");
    };
}

특히 AUTH 타입은 처음에 단순히 “인증”으로 내려서 프론트에 “인증 (1)”처럼 보이는 문제가 있었다. 이를 userRepository 기반으로 바꾸면서 AUTH 로그도 “김철수 (1)”처럼 자연스럽게 보이도록 수정했다.


9. Frontend 구조 설계

프론트는 기존 VendorsList 패턴을 그대로 참고했다. 즉 CoreERP 전체 프론트 구조를 깨지 않고, 기존 조회 페이지와 같은 방식으로 Audit Log 화면을 구현했다.

구성한 파일은 다음과 같다.

  • types/audit.ts
  • mappers/auditMapper.ts
  • apis/auditApi.ts
  • features/audit/AuditLogPage.tsx
  • features/audit/components/AuditLogFilterPanel.tsx
  • features/audit/components/AuditLogTable.tsx
  • features/audit/components/AuditLogDetailModal.tsx
  • features/audit/utils/auditQueryKeys.ts
  • features/audit/utils/auditUiMapper.ts

즉 Backend에서 반환한 데이터를 mapper로 정규화하고, TanStack Query로 호출하고, Table과 DetailModal에 분리해서 렌더링하는 구조다. 이 방식은 이미 Item, Vendor, OutboundHistory에서 사용 중인 패턴이라 프로젝트 전체 통일감도 유지할 수 있었다.


10. 프론트 타입과 mapper 정리

API 응답은 항상 신뢰할 수 없기 때문에 normalize 과정이 필요하다. Vendor 페이지와 같은 방식으로 auditMapper를 만들어 문자열, 숫자, enum을 정규화했다.

export type AuditLog = {
  id?: number;
  userId?: number;
  username: string;
  action: Exclude<AuditAction, "">;
  entityType: Exclude<AuditEntityType, "">;
  entityId?: number;
  entityDisplayName: string;
  description: string;
  detailJson: string;
  ipAddress: string;
  userAgent: string;
  createdAt: string;
};

export function normalizeAuditLog(raw: any): AuditLog {
  const id = toNum(raw?.id, NaN);
  const userId = toNum(raw?.userId, NaN);
  const entityId = toNum(raw?.entityId, NaN);

  return {
    id: Number.isFinite(id) ? id : undefined,
    userId: Number.isFinite(userId) ? userId : undefined,
    username: toStr(raw?.username).trim(),
    action: normalizeAuditAction(raw?.action),
    entityType: normalizeAuditEntityType(raw?.entityType),
    entityId: Number.isFinite(entityId) ? entityId : undefined,
    entityDisplayName: toStr(raw?.entityDisplayName).trim(),
    description: toStr(raw?.description),
    detailJson: toStr(raw?.detailJson),
    ipAddress: toStr(raw?.ipAddress),
    userAgent: toStr(raw?.userAgent),
    createdAt: toStr(raw?.createdAt),
  };
}

이 과정을 두면 API 응답이 살짝 달라져도 프론트 컴포넌트는 최대한 안정적으로 유지할 수 있다. 또한 createdAt 포맷, action label, entityType label 같은 UI 포맷도 mapper에 모아두면 컴포넌트가 훨씬 깔끔해진다.


11. 날짜 포맷 문제와 수정

처음에는 createdAt을 toLocaleString("ko-KR") 기반으로 포맷했는데, 브라우저 환경에 따라 결과가 들쭉날쭉했다. 예를 들어 어떤 환경에서는 초가 보이고, 어떤 환경에서는 오전/오후가 붙는 문제가 생겼다. ERP 테이블에서는 날짜 포맷이 고정되어야 하므로 직접 문자열을 조립하는 방식으로 수정했다.

export function formatDateTime(value: string): string {
  if (!value) return "-";

  const d = new Date(value);
  if (Number.isNaN(d.getTime())) return value;

  const year = d.getFullYear();
  const month = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  const hour = String(d.getHours()).padStart(2, "0");
  const minute = String(d.getMinutes()).padStart(2, "0");

  return `${year}-${month}-${day} ${hour}:${minute}`;
}

이제 화면에는 항상 다음처럼 동일한 형식으로 보인다.

  • 2026-03-08 19:44

운영 화면 입장에서는 이런 일관성이 훨씬 중요하다.


12. AuditLog 조회 페이지 구성

조회 페이지는 기존 Vendor 조회 화면 패턴을 그대로 따라갔다. 상단에는 결과 개수와 페이지 이동 컨트롤이 있고, 우측 또는 하단에는 필터 패널이 있으며, 중앙에는 테이블이 배치된다. 상세보기 버튼을 누르면 Modal이 열려 detailJson을 pretty print 형태로 보여준다.

사용한 필터는 다음과 같다.

  • 시작일
  • 종료일
  • 행위(action)
  • 대상(entityType)
  • 사용자 ID
  • 검색어(keyword)

테이블 컬럼은 다음과 같이 구성했다.

  • 시간
  • 사용자
  • 행위
  • 대상
  • 대상 ID
  • 설명
  • IP
  • 상세보기

특히 대상 ID 컬럼은 단순 숫자가 아니라 entityDisplayName과 합쳐서 보여주도록 수정했다.

function buildEntityText(row: AuditLog) {
  if (row.entityDisplayName && row.entityId) {
    return `${row.entityDisplayName} (${row.entityId})`;
  }
  if (row.entityDisplayName) {
    return row.entityDisplayName;
  }
  if (row.entityId) {
    return String(row.entityId);
  }
  return "-";
}

13. 라우터와 레이아웃 메뉴 연결

감사 로그 페이지는 마스터 계정 전용 메뉴로 연결했다. 즉 일반 사원이나 관리자에게 모두 노출하는 것이 아니라, 현재 구조상 MASTER일 때만 “기준 정보” 그룹이 보이도록 했다.

라우터는 아래처럼 연결했다.

import AuditLogPage from "@/features/audit/AuditLogPage";

<Route path="master/audit-logs" element={<AuditLogPage />} />

Layout 사이드바에는 “기준 정보” 그룹 안에 “감사 로그” 메뉴를 추가했다.

if (user?.role === "MASTER") {
  items.push({
    key: "master",
    title: "기준 정보",
    icon: <IconDatabase className="nav-icon" />,
    items: [
      { to: "/master/items", label: "품목 조회", end: true },
      { to: "/master/items/new", label: "품목 등록" },
      { to: "/master/vendors", label: "업체 정보" },
      { to: "/master/audit-logs", label: "감사 로그" },
    ],
  });
}

처음에는 메뉴가 보이지 않아 오류처럼 느껴졌지만, 실제 원인은 MASTER 계정이 아닌 상태로 확인했기 때문이었다. MASTER로 로그인하니 정상적으로 메뉴가 표시되었고, 이는 권한 조건이 의도대로 동작하고 있다는 의미였다.


14. 데이터 흐름 정리

이번 기능 구현에서 가장 중요했던 것은 프론트와 백엔드, 그리고 DB의 연결 흐름을 명확히 유지하는 것이었다. CoreERP 전체 관점에서 Audit Log는 다음 흐름으로 동작한다.

  • 사용자가 프론트 화면에서 품목 생성, 발주 취소, 입고 등록 같은 작업을 수행한다.
  • React가 API를 호출한다.
  • Spring Boot의 각 Service가 실제 비즈니스 로직을 수행한다.
  • 상태 변경이 성공하면 AuditLogService가 audit_logs 테이블에 기록한다.
  • 나중에 관리자 화면에서 AuditLogPage가 /api/audit-logs를 호출한다.
  • AuditLogQueryService가 검색 조건에 맞는 데이터를 조회하고 entityDisplayName까지 채운다.
  • React가 mapper로 정규화한 뒤 테이블에 렌더링한다.

즉 이 기능은 단순한 UI 추가가 아니라, Frontend → Backend → Database → Backend → Frontend로 이어지는 전체 흐름이 완성되어야 비로소 의미가 생긴다.


15. 트러블슈팅 정리

15-1. QueryService에 로그를 넣어야 하는가?

처음에는 로그를 어디에 넣어야 하는지 헷갈릴 수 있었다. 하지만 감사 로그는 실제 상태가 바뀌는 순간 남겨야 하므로 QueryService가 아니라 Service에 넣는 것이 맞다. 즉 조회 로직이 아니라 create, update, cancel, confirm 같은 메서드가 들어있는 서비스에 넣는 것이 정답이었다.

15-2. AUTH 타입이 “인증 (1)”로 보이는 문제

처음에는 AUTH 타입의 entityDisplayName을 단순 문자열 “인증”으로 내려서, 프론트에서 “인증 (1)”처럼 애매하게 보였다. 이후 AUTH 타입도 userRepository로 사용자 이름을 조회하도록 바꾸면서 “김철수 (1)” 형태로 개선했다.

15-3. 날짜 포맷이 브라우저마다 다른 문제

toLocaleString 기반 포맷은 환경마다 결과가 달랐다. ERP 화면에서는 일관성이 중요하므로 yyyy-MM-dd HH:mm 형식으로 직접 조립하도록 수정했다.

15-4. 메뉴가 보이지 않는 문제

사이드바에 감사 로그 메뉴가 보이지 않아 UI 문제처럼 보였지만, 실제 원인은 MASTER 계정이 아닌 상태였다. Layout의 role 조건을 다시 확인한 뒤 MASTER 계정으로 로그인하니 정상 표시되었다.


16. 이번 단계의 의미

이번 작업은 단순히 “감사 로그 기능 하나 추가” 이상의 의미가 있다. ERP 프로젝트에서 Audit Log는 운영 신뢰성과 직결되는 기능이다. 즉, 데이터 변경이 실제로 추적 가능하다는 것은 프로젝트의 완성도를 한 단계 끌어올린다.

특히 이번 단계에서 좋았던 점은 다음과 같다.

  • 서비스 레이어 중심으로 기록 시점을 정확히 잡았다.
  • 공통 저장 로직과 조회 로직을 분리했다.
  • 대상 이름(entityDisplayName)을 응답에 포함해 운영 화면 가독성을 높였다.
  • 기존 Vendor / ItemList 패턴을 활용해 프론트 구조를 통일했다.
  • SecurityContext 구조(principal=loginId, details=userId)를 감사 로그에도 자연스럽게 연결했다.

즉 이번 작업은 CoreERP 프로젝트의 실무형 구조를 더 단단하게 만든 단계라고 볼 수 있다.


17. 마무리

이번 Audit Log 기능 구현을 통해 CoreERP는 단순 CRUD 수준을 넘어서 운영 이력 추적이 가능한 ERP 형태에 한 걸음 더 가까워졌다. Backend에서는 기록 구조와 조회 구조를 정리했고, Frontend에서는 이를 실제로 확인할 수 있는 관리자 화면까지 연결했다.

지금 시점에서 CoreERP는 다음과 같은 핵심 기능을 갖추게 되었다.

  • 기준 정보 관리
  • 재고 관리
  • 발주 / 입고 / 출고 / 이동 관리
  • JWT 기반 인증
  • 감사 로그(Audit Log) 기록 및 조회

프로젝트 전체 관점에서 보면 이번 단계는 “기능 추가”라기보다 “운영 관점의 안정성 확보”에 가깝다. 앞으로는 이 기반 위에서 버그 수정과 안정화, 권한별 접근 제어, 화면 다듬기 같은 작업으로 자연스럽게 이어갈 수 있다.


18. 핵심 코드 모음

마지막으로 이번 단계에서 가장 핵심이었던 코드만 다시 짧게 정리하면 아래와 같다.

현재 로그인 사용자 추출

Object principal = authentication.getPrincipal();
Object details = authentication.getDetails();

감사 로그 저장

auditLogService.log(
        AuditAction.CREATE,
        AuditEntityType.ITEM,
        saved.getItemId(),
        "품목 생성",
        detail
);

감사 로그 조회 API

@GetMapping
public Page<AuditLogResponse> search(...) {
    return auditLogQueryService.search(...);
}

대상 표시명 조회

case USER -> userRepository.findById(entityId)
        .map(user -> user.getName())
        .orElse(null);

프론트 날짜 포맷 고정

return `${year}-${month}-${day} ${hour}:${minute}`;

대상 ID를 이름과 함께 표시

return `${row.entityDisplayName} (${row.entityId})`;

이번 단계 정리는 여기까지다. 이제 CoreERP는 단순 기능 구현 단계를 넘어서, 운영 이력과 관리 화면까지 갖춘 구조로 발전했다.

profile
Develop

0개의 댓글