개요
항목 | 내용 |
기간 | Audit Log: 2025.04, 페이징: 2025.04 (유지보수 2025.11) |
역할 | 설계 및 구현 주도 |
기술 스택 | Kotlin, Spring Boot 2.x/3.x, Spring Cloud Sleuth, Micrometer Tracing, Exposed ORM |
영향 범위 | 100명 규모 개발 조직 전체 |
배경
커넥트웨이브는 레거시 시스템에서 신규 플랫폼으로의 전환을 진행하고 있었습니다. 이 과정에서 두 가지 조직 차원의 문제가 있었습니다.
Audit Log 문제
•
레거시 환경에서는 관리자나 배치/실시간 시스템의 CUD 행위에 대한 감사 로그가 부재하거나, 시스템별로 파편화되어 있었음
•
장애 발생 시 "누가, 언제, 어떤 데이터를 변경했는지" 추적이 어려웠음
•
신규 플랫폼에서는 모든 API에서 일관된 감사 로그를 남기기로 결정
페이징 문제
•
Kotlin Exposed ORM을 도입했으나, Spring Data의 Pageable처럼 표준화된 페이징 시스템이 없었음
•
각 팀이 서로 다른 방식으로 페이징을 구현하고 있었고, 디버깅 및 유지보수에 비용이 발생
문제
Audit Log
•
Spring Boot 2.x(Spring Cloud Sleuth) 기반의 임시 프로젝트와 Spring Boot 3.x(Micrometer Tracing) 기반의 신규 프로젝트가 동시에 존재
•
두 환경 모두에서 동작하면서도, 저장소(DB, 파일, 외부 시스템 등)에 독립적인 공용 라이브러리가 필요
페이징
•
RDBMS(PostgreSQL)와 MongoDB를 모두 사용하는 환경이어서, 특정 DB에 종속되지 않는 설계가 필요
•
기존 Exposed ORM의 자연스러운 DSL 사용 방식을 깨지 않으면서 페이징을 통합해야 했음
무엇을 했나
Audit Log 라이브러리 설계
flowchart TD
A[HTTP 요청] --> B[AuditFilter<br>OncePerRequestFilter]
B -->|헤더를 Baggage로 전파| C[Micrometer Tracing<br>Baggage 저장소]
B --> D[Controller / DataFetcher]
D -->|"@AuditInfo 어노테이션"| E[ArgumentResolver]
E -->|tracer.allBaggage| C
E --> F[AuditInfoDTO로 바인딩]
D -->|"AuditHandler 호출"| G{AuditHandler}
G -->|tracer.allBaggage| C
G --> H[LoggingAuditHandler<br>기본 구현체]
G --> I[커스텀 구현체<br>DB 저장 등]Mermaid
복사
1. AuditHandler — sealed interface 기반 처리 전략 추상화
감사 데이터를 어떻게 처리할지(DB 저장, 로깅, 메시지 큐 등)를 sealed interface로 추상화하고, 비동기 처리도 기본 지원:
sealed interface AuditHandler {
fun handle()
fun handleAsync(executor: Executor) {
CompletableFuture.runAsync({ handle() }, executor)
}
}
abstract class MicrometerTracingAuditHandler(
private val tracer: Tracer,
) : AuditHandler {
override fun handle() {
val auditInfo = tracer.allBaggage // Baggage에서 감사 정보 수집
handle(auditInfo)
}
abstract fun handle(auditInfo: Map<String, String>)
}
Kotlin
복사
@ConditionalOnMissingBean을 활용하여 커스텀 구현체가 없으면 로깅 기반 기본 핸들러를 자동 등록:
@Bean
@ConditionalOnMissingBean(AuditHandler::class)
fun defaultAuditHandler(tracer: Tracer) = LoggingAuditHandler(tracer)
Kotlin
복사
2. AuditFilter — HTTP 헤더를 Micrometer Baggage로 자동 전파
HTTP 헤더의 감사 정보(who, when, where, what)를 Micrometer Tracing의 Baggage로 자동 설정하여, 서비스 내부 어디서든 tracer.allBaggage로 감사 정보에 접근 가능
3. @AuditInfo + ArgumentResolver — 컨트롤러/리졸버에서 직접 바인딩
GraphQL DataFetcher와 Spring MVC Controller 모두에서 사용 가능하도록 두 종류의 ArgumentResolver를 구현:
// 사용하는 쪽 코드 — 어노테이션 하나로 감사 정보 주입
fun updateItem(
@AuditInfo auditInfo: AuditInfoDTO, // who, when, where, what 자동 바인딩
input: UpdateItemInput
) { ... }
Kotlin
복사
•
Spring Boot 2.x(Sleuth)와 3.x(Micrometer Tracing)를 동시에 지원하기 위해 같은 인터페이스 + 각 라이브러리별 구현체를 별도 모듈로 분리하여 각 프로젝트에서 해당하는 모듈만 import
Exposed ORM 페이징 라이브러리 설계
확장 함수 설계:
// 정렬 조건을 Exposed 쿼리에 매핑하는 람다 타입
typealias SortColumMapper = (Sort.Order) -> Pair<Expression<*>, SortOrder>?
// Offset 페이징 확장 함수 — Query에 체이닝하여 사용
fun Query.offsetPagedQuery(
page: Pageable,
sortColumMapper: SortColumMapper = { null },
): Query {
val sortOrders = page.sort
.map { sortColumMapper(it) }
.filterNotNull()
.toTypedArray()
return this
.orderBy(*sortOrders)
.limit(page.pageSize)
.offset(page.offsetAsZeroBase)
}
// 쿼리 결과를 PageResponse로 변환 — mapper 함수 주입
fun <T> Query.mapToOffsetPageResponse(
pageable: Pageable,
total: Long,
contentMapper: (ResultRow) -> T,
): PageResponse<T> { ... }
// 커서 기반 페이징도 동일한 패턴으로 지원
fun Query.cursorPagedQuery(
page: Pageable,
cursorCondition: SqlExpressionBuilder.() -> Op<Boolean>,
sortColumMapper: SortColumMapper = { null },
): Query { ... }
Kotlin
복사
사용하는 쪽 코드:
val query = ItemTable.selectAll()
query.apply {
param.categoryId?.let { andWhere { ItemTable.categoryId eq it } }
}
val totalCount = query.count()
// 한 줄로 페이징 적용 + 응답 변환
return query.offsetPagedQuery(pageable).mapToOffsetPageResponse(pageable, totalCount) {
it.toItemDTO()
}
Kotlin
복사
•
Query.offsetPagedQuery() / Query.cursorPagedQuery() 확장 함수로 Exposed ORM의 기존 쿼리 빌더에 자연스럽게 체이닝
•
SortColumMapper typealias로 정렬 조건 변환을 외부에서 주입받아 유연성 확보
•
Offset 페이징과 Cursor 페이징 모두 동일한 패턴으로 지원 — PageResponse로 일관된 응답 반환
•
PageResponse.map() 확장 함수로 레이어 간 DTO 변환 시에도 페이징 메타데이터 유지
결과
성과
•
신규 플랫폼 전체에 Audit Log 라이브러리 적용 — 15개 멀티 모듈에 적용
•
페이징 라이브러리 도입 후 전체 개발 조직의 페이징 구현 방식 통일
•
페이징 관련 코드 리뷰 시 "각자 다른 방식" 문제가 사라짐
•
신규 개발자 온보딩 시 페이징 처리 학습 비용 감소
•
Audit Log를 통한 장애 추적이 가능해짐
배운 점
•
각 라이브러리에 KDoc 주석을 상세하게 작성하는 것이 대규모 협업에서 얼마나 중요한지를 체감함 — 100명 규모 조직에서 라이브러리를 배포하면 직접 설명할 수 없는 사람들이 대부분이고, 문서가 코드를 대신함
•
사용성과 유지보수성 둘 다 포기할 수 없다는 점을 배움. 확장 함수로 쓰기 편하게 만들면서도, 내부 설계를 바꿀 때 기존 사용자에게 영향을 주지 않도록 인터페이스를 설계해야 했음