12. 데이터 시스템의 미래

2025. 11. 10. 19:46·Book/데이터 중심 애플리케이션 설계

❒ 개요


  • 이번 장에서는 미래에는 어떻게 돼야 하는지에 대해서 설명함.
  • 앞에서 배운 아이디어들을 함께 모아 그것을 기반으로 미래를 고찰하자.

 

 

 

 

 

❒ 1. 데이터 통합


  • 가장 적절한 소프트웨어 도구를 선택하는 것은 상황에 따라 다름.
  • 선택의 폭이 넓은 경우
    1. 소프트웨어 제품과 그 제품고가 잘 어울리는 환경 사이의 대응 관계를 파악하는 것
    2. 복잡한 환경에서는 데이터를 여러 가지 다른 방법으로 사용하기 때문에, 소프트웨어가 모든 상황에 대처할 가능성이 낮음.  

 

 

🌀 1-1. 파생 데이터에 특화된 도구의 결합

개요

 

  • OLTP용 데이터베이스(트랜잭션 처리 DB)만으로는 임의 키워드 검색(full-text search) 같은 걸 잘 처리하기 어려움.
  • PostgreSQL 같은 DB도 풀텍스트 기능을 제공하지만
    • 복잡하고 정교한 검색을 하려면 Elasticsearch, Solr 같은 전문 검색 도구가 더 적합
  • 반대로, 검색 인덱스는 system of record로 쓰기엔 내구성/일관성 측면에서 좋지 않음.
    • 따라서, 중요한 데이터는 여전히 RDB에 저장해야 함.
  • 그래서 실무에서는 아래의 도구를 조합해서 요구사항을 만족 시킴 
    • “쓰기/정합성”은 DB
    • “검색/질의”는 검색엔진
  • 같은 비즈니스 데이터가 여러 시스템에 복제·변환돼 존재하기 때문에
    • 각 시스템을 어떻게 통합하고 동기화할지가 점점 더 어려움.

 

 

데이터플로에 대한 추론 (Reasoning about dataflows)
  • 모든 쓰기의 순서를 결정하는 단일 시스템으로 모든 사용자 입력을 밀어 넣을 수 있다면
    • 쓰기를 같은 순서로 처리해 데이터를 다른 표현형으로 파생하기가 훨씬 쉬워짐
    •  

 

파생 데이터 vs 분산 트랜잭션
  • 추상적인 수준에서 보면 파생 데이터와 분산 트랜잭션은 다른 방식으로 유사한 목표를 달성함.
    1. 분산 트랜잭션
      • 상호 배타적인 잠금을 사용해 순서를 결정
      • 원자적 커밋을 사용해 변경 효과가 정확히 한 번 나타나도록 보장
    2. 파생 데이터
      • 로그를 사용해서 순서를 결정
      • 결정적 재시도와 멱등성을 기반으로 함.
  • 두 시스템의 가장 큰 차이점
    1. 트랜잭션 시스템
      • 선형성을 지원함.
    2. 파생 데이터 시스템
      • 비동기로 갱신되기 때문에 기본적으로 동시간 갱신 보장을 하지 않음.

 

 

전체 순서화의 제약 (The limits of total ordering)
  • 모든 이벤트를 하나의 글로벌 순서로 정하는 것은 작고 단순한 시스템에서는 가능
  • 하지만  파티셔닝, 다중 데이터센터, 마이크로서비스, 오프라인 클라이언트 등 현실적인 요구가 늘어나면
          전역 total order를 유지하는 것이 근본적으로 어렵고, 현재 합의 알고리즘으로도 한계가 있다

 

인과성 획득을 위한 이벤트 순서화
➔ 모든 이벤트를 전역 순서로 정하지 못하더라도, 중요한 ‘인과관계(causality)’만큼은 어떻게 보존할 것인가?
  • 이벤트 간 인과성이 없는 경우 전체 순서가 정해지지 않아도 큰 문제가 아님.
    • 동시에 발생한 이벤트는 임의의 순서로 정할 수 있기 때문임.
  • 예시 : 친구 끊고, 뒷담
    • 친구 상태를 저장하는 곳과 메시지를 저장하는 곳이 다른 시스템의 경우
      • 친구 끊기 이벤트와 메시지 보내기 이벤트 사이의 순서 의존성이 없음.
      • 이 경우, 뒷담 깐거를 들길 수 있음. 
  • 이 문제를 해결할 수 있는 3가지 방법 제시 
    1. 논리적 타임스탬프(Logical timestamps)
    2. “사용자가 본 상태”를 이벤트로 기록하기
    3. 충돌 해소(Conflict resolution) 알고리즘

 

 

🌀1-2. 일괄 처리와 스트림 처리

필자는...
  • 데이터 통합의 목표는 데이터를 올바른 저장소에 올바른 형태로 두는 것이라고 생각함.
  • 이렇게 하기 위해서는 아래의 과정을 거쳐야 함.
    • 입력 ➔ 형 변환 ➔ 필터링 ➔ 집계 ➔ 모델 학습 ➔ 평가 ➔ 출력
  • 일괄 처리자와 스트림 처리자는 이 목표를 달성하기 위한 도구임.

 

파생 상태 유지
  • 입력과 출력을 잘 정의한 결정적 함수의 원리는
    1. 내결함성에 도움이 됨.
    2. 조직 내의 데이터플로 추론을 단순화 함.
  • 이론상으로 파생 데이터 시스템은
    • 관계형 DB가 색인할 테이블에 기록하는 트랜잭션 내에서 보조 색인을
            동기식으로 갱신하는 것처럼 동기식으로 운영할 수 있음.
    • 하지만 비동기 방식을 사용하면 이벤트 로그 기반 시스템을 훨씬 견고하게 만들 수 있음.
  • 분산 트랜잭션은
    • 참여 장비 일부가 실패하면 어보트하기 때문에 나머지 시스템으로 실패가 확산되어 실패가 증폭되는 경향이 있음.

 

애플리케이션 발전을 위한 데이터 재처리
  • 기존 데이터를 재처리하는 것은 시스템을 유지보수하기 위한 좋은 메커니즘으로
    • 새로운 기능 추가와 요구사항에 대응할 수 있게 만듬.
  • 재처리 없이 스키마를 변경하는 작업은
    • 레코드에 새 선택적 필드를 추가하거나
    • 새로운 타입의 레코드를 추가하는 것과 같은 간단한 것으로 제한됨.
  • 이런 제약은 읽기 스키마와 쓰기 스키마 모두에 해당
  • 파생뷰를 사용하면 점진적 발전이 가능함.
    • 데이터셋을 재구축해야 할 경우 갑자기 뷰 이전을 수행할 필요가 없음.
    • 신/구 버전의 독립적인 파생 뷰를 만들 수 있음.
    • 즉, 뭔가 잘못됐을 때 쉽게 롤백할 수 있음.

 

람다 아키텍쳐
  • 일괄 처리를 과거 데이터를 재처리하는데 사용하고, 최근 갱신 데이터를 처리하는데 스트림 처리르 사용
  • 핵심 아이디어
    • 람다 아키텍처를 쉽게 설명하면, 들어오는 모든 데이터를 삭제하거나 수정하지 않고 일단 저장해두고,
            필요할 때마다 저장된 데이터를 다양하게 분석하거나 복구하는 구조라고 이해하면 됨.
    • 즉, 데이터를 바꾸거나 지우지 않고 계속 추가만 해서 "모든 기록을 남겨두는 방식"
    • 실제로 잘못된 결과가 나오거나 새로운 분석 방법이 생겼을 때도 원본 데이터를 바탕으로 다시 계산하거나,
            원하는 정보로 바꿀 수 있음.​
  • 이 아키텍쳐에서 스트림 처리자는
    • 이벤트를 소비해 근사 갱신을 뷰에 빠르게 반영함.
    • 이후에 일괄 처리자가 같은 이벤트 집합을 소비해 정확한 버전의 파생 뷰에 반영함.
  • 이 아키텍쳐의 설계 배경은
    • 일괄 처리는 간단해서 버그가 생길 가능성이 적음.
    • 반면에 스트림 처리자는 신뢰성이 떨어지고 내결함성을 확보하기 어렵다는 것.
  • 근데 필자는 람다 아키텍쳐에 문제가 있다고 생각함
    1. 배치 코드 + 스트림 코드를 두 벌 유지해야 함.
    2. 전체 재처리는 비싸서, 결국 “증분 배치”가 필요해짐. (특정 시간의 데이터만 배치 처리하는 등..)

 

일괄 처리와 스트림 처리의 통합
  • 최근에는 같은 시스템에서 일괄 처리 연산과 스트림 연산을 모두 구현함으로써
          람다 아키텍처의 단점을 빼고 장점만 취할 수 있게 하는 작업이 진행되고 있음.
  • 배치와 스트림을 한 시스템으로 통합하려면 다음 세 가지 기능이 필요함.
    • 최근 이벤트 스트림을 다루는 처리 엔진에서 과거 이벤트를 재생하는 능력
    • 스트림 처리에서도 Exactly-once semantics 보장
    • 이벤트 시간(event time) 기준 윈도우링 지원
  • 위 세 가지 기능을 가지고 있다면
    • 람다 아키텍처처의 단점은 없앨 수 있고,
    • 하나의 스트리밍/데이터플로우 엔진이
      • 실시간 처리, 과거 재처리, 파생 뷰 유지 를 모두 담당하는 통합 모델을 만들 수 있음.
 

 

 

 

 

❐ 2. 데이터베이스 언번들링 (데이터베이스의 분리)


  • 저자는 운영체제(Unix) 와 데이터베이스(DB) 를 비교하면서 
          두 시스템이 같은 목적을 다른 철학으로 해결한 역사적 흐름을 설명하는 파트
  • Unix는 단순하지만 직접 해야 할 게 많음
  • DB는 강력하지만 내부가 감춰져 있음.
  • 현대 시스템은 이 둘의 장점을 결합해 “유연하면서도 강력한 데이터 시스템”을 지향함.
    • 이것이 바로 데이터베이스 언번들링의 출발점

 

 

🌀 2-1. 데이터 저장소 기술 구성하기

데이터베이스가 제공하는 다양한 기능에 대한 요약 설명
  • 보조 색인은 필드 값을 기반으로 레코드를 효율적으로 검색할 수 있는 기능이다.
  • 구체화 뷰는 질의 결과를 미리 연산한 캐시의 일종이다
  • 복제 로그는 데이터의 복사본을 다른 노드에 최신 상태로 유지하는 기능이다.
  • Full-text 검색 색인은 텍스트에서 키워드 검색을 가능하게 하는 기능이다.

 

색인 생성하기
  • 필자는 인덱스 생성이 단순한 DB 내부 작업이 아니라,
    • 데이터 재처리(reprocessing) 와 파생 데이터(derived data) 의 일종임을 강조
  • CREATE INDEX 실행 시 일어나는 일
    1. 일관된 스냅샷(consisent snapshot) 확보
      • 인덱스를 생성하려면, 특정 시점의 테이블 상태를 완전하게 읽어야 함.
      • DB는 트랜잭션 격리 수준을 활용해 일관된 시점의 데이터 복사본(snapshot) 을 확보.
    2. 인덱싱할 필드 추출 및 정렬
      • 예: CREATE INDEX ON users(email)
      • 모든 users.email 값을 읽어서 정렬 후, B-Tree 혹은 다른 인덱스 구조로 저장.
    3. 인덱스 파일 쓰기
      • 정렬된 데이터를 기반으로 새로운 인덱스 파일을 디스크에 기록.
    4. 스냅샷 이후에 발생한 쓰기(write) 처리
      • 인덱스 생성 중에도 테이블은 계속 쓰기될 수 있음.
      • DB는 “스냅샷 이후 발생한 변경분(backlog)”을 추적하여 새 인덱스에 반영.
    5. 지속적인 동기화
      • 인덱스 생성이 끝나면, 이후 트랜잭션이 테이블에 쓰기할 때마다 인덱스도 함께 갱신..
  • 즉, 새 인덱스를 만든다는 것은
    • 기존 데이터를 전부 복제해서 새로운 형태로 저장하는 것과 같음

 

모든 것의 메타데이터베이스 (The meta-database of everything)
  • 일괄 처리와 스트림 처리로 유지하는 파생 데이터 시스템은 마치 다양한 색인 유형과 비슷함.
  • 기존에는 하나의 DB 엔진 안에 모든 인덱스 기능 포함
    • B-tree, Hash, Spatial 등
  • 각 기능을 독립된 전문 시스템으로 분리(Unbundling) 하는 방향으로 진화
    • 텍스트 검색 : Elasticsearch, OpenSearch
    • 이벤트 스트림 저장 : Kafka
    • ...
  • 필자는 서로다른 저장소와 처리 도구를 사용하지만 하나의 응집된 시스템으로
          구성할 수 있는 2가지 방법이 있다고 생각함.
    1. 연합 데이터 베이스 : 읽기를 통합
      • 여러 종류의 저장 엔진과 처리 방식을 대상으로 하나의 통합 쿼리 인터페이스를 제공하는 접근.
      • 즉, 다양한 데이터 소스를 하나의 SQL처럼 읽을 수 있게 하는 방식.
      • 쓰기(write) 일관성 유지나 트랜잭션 처리에는 약함
      • PostgreSQL Foreign Data Wrapper (FDW)\
        • 외부의 다른 데이터 소스(MySQL, CSV, REST API 등)를 PostgreSQL 테이블처럼 읽을 수 있음.
    2. 언번들링 데이터베이스 : 쓰기를 통합
      • 여러 저장소 간에 쓰기(write) 를 일관되게 동기화(synchronize) 하는 문제를 다룸.
      • 즉, “데이터 변경을 여러 시스템에 안정적으로 반영” 하는 방식.
      • Change Data Capture (CDC), Event Log, Outbox Pattern 등을 통한 데이터 동기화.
      • 예: 트랜잭션 로그를 Kafka로 보내고, 이를 검색 인덱스나 캐시 시스템이 구독함.

 

언번들링이 동작하게 만들기

 

  • Federation vs Unbundling — “같은 동전의 양면”
    • Federation : 여러 데이터 소스를 하나의 인터페이스로 읽기(read) 통합
    • Unbundling : 여러 데이터 시스템 간 쓰기(write) 를 일관되게 동기화(synchronize)
            → 둘 다 이질적인 시스템을 결합(composition) 한다는 점에서 동일한 목표를 가진다.
  • 쓰기 동기화(Write Synchronization)는 훨씬 어려움
    • 읽기 통합은 단지 “데이터 모델 간 매핑(mapping)” 문제라서 상대적으로 단순함.
    • 하지만 쓰기 통합은 여러 시스템에 걸쳐 데이터를 정합성 있게 동시에 반영해야 함.
    • 즉, “한쪽에 쓴 내용이 다른 쪽에도 반영되어야 한다”는 문제는 훨씬 복잡하다.
  • 전통적 접근: 분산 트랜잭션(Distributed Transactions)
    • 과거에는 여러 저장소에 걸친 쓰기를 2PC 등의 분산 트랜잭션으로 처리하려고 했다.
    • 하지만 이 방식은 다음과 같은 문제가 있음:
      • 이기종 시스템 간에는 표준화된 트랜잭션 프로토콜이 없음.
      • 네트워크 지연, 장애 시 전체 시스템이 멈출 위험(결합도가 너무 높음).
      • 따라서 확장성과 견고성이 떨어지는 구조가 됨.
  • 추천 접근: 이벤트 로그(Event Log) 기반의 비동기 통합
    • 저자는 대신 다음 방식을 제안 ➔  “비동기 이벤트 로그 + 멱등(idempotent) 쓰기”
    • 즉, 데이터를 쓸 때 즉시 여러 시스템에 쓰는 대신,
      • 하나의 이벤트 로그에 변경을 기록하고
      • 다른 시스템들은 이 로그를 구독(consume)하며 상태를 갱신
    • 이 방식은 Derived Data System, Change Data Capture(CDC) 와 같은 형태로 구현된다.
  • 왜 이벤트 로그 방식이 더 나은가
    1. 시스템 수준의 이점 - 느슨한 결합
      • 각 구성 요소가 비동기적으로 통신하므로, 일부 시스템이 느리거나 장애가 나도 전체가 멈추지 않음.
      • 이벤트 로그가 버퍼 역할을 하며 데이터 손실을 방지.
      • 장애가 복구되면 소비자(consumer) 는 이벤트를 재처리(catch up) 가능.
      • 반대로 분산 트랜잭션은 동기적으로 묶여 있어서 부분 장애가 전체 장애로 확산(escalation) 되기 쉽다.
    2. 조직 수준의 이점 — 독립 개발과 유지보수
      • 각 팀이 자신의 시스템에만 집중할 수 있다.
      • 팀 간 인터페이스(계약)가 명확해짐.
  • 이벤트 로그의 핵심 속성 (아래 속성이 결합되어 분리된 시스템 간 강력한 일관성을 유지하는 기능하게 함)
    • 내구성 (Durability)
    • 순서 보장 (Ordering)
    • 멱등성 (Idempotence)
  • 결론
    • Unbundling(분리된 데이터 시스템의 통합) 을 가능하게 만드는 핵심은
            분산 트랜잭션이 아니라 비동기 이벤트 로그(asynchronous log) 기반의 통합이다.
    • 시스템적으로는 느슨한 결합과 복원력(resilience)을 확보하고,
    • 조직적으로는 독립된 개발·운영을 가능하게 한다.
    • 결국 “로그(Log)”는 데이터 통합의 중심 축이자,
            현대 분산 아키텍처에서 데이터 일관성을 유지하는 가장 현실적인 수단이다.

 

언번들링 vs 통합 시스템
  • 저자는 “Unbundling이 미래의 방향”이라고 말하면서도,
          통합형 데이터베이스의 역할이 여전히 필수적임을 강조
          즉, “모든 걸 분리하라”가 아니라 “필요할 때만 분리하라”
  • Unbundling이 대체가 아니라 “보완”
    • “Unbundling(분리형)” 접근이 데이터베이스를 완전히 대체하지는 않는다.
    • 즉, 스트림/배치 시스템의 결과를 저장하고 서빙(Serving) 하는 역할은 여전히 DB가 맡는다.
  • 필요없는 확장은 낭비

 

뭐가 빠졌지?
  • 우리는 Kafka, DB, Search, Stream Processor 등 훌륭한 부품을 이미 갖고 있음
  • 하지만 이들을 Unix 파이프처럼 단순하게 조합할 “언어(shell)” 가 아직 없다.

 

 

🌀 2-2. 데이터플로 주변 애플리케이션 설계

 

  • 데이터 파생(derivation)은 모든 시스템의 핵심 동작이다.
  • 하지만 단순 인덱스 생성 외의 복잡한 파생 로직은 애플리케이션 코드로 직접 구현해야 한다.
  • 대부분의 데이터베이스는 이러한 “파생 로직(derivation function)”을 내장적으로 다루지 못한다.
          ➔ 이는 Unbundled Architecture 의 핵심 과제 중 하나다.

 

파생 함수로서의 애플리케이션 코드 
  • 모든 파생 데이터는 원본 데이터를 변환 함수(transformation function) 를 통해 얻어짐. 
  • 자동화된 파생 함수 vs 커스텀 파생 함수
    • 자동화된 파생 함수
      • 보조 인덱스(secondary index) 생성은 너무 자주 쓰이기 때문에
              DB가 내부적으로 자동 처리 기능으로 내장 (CREATE INDEX).
      • 즉, derivation function이 DB 엔진에 내장되어 있음.
    • 커스텀 파생 함수
      • 전문 검색, 머신러닝, 캐시 구축 등은 도메인 특화 로직이 필요함.
      • 따라서 표준화된 기능이 아니라 직접 코드로 구현해야 함.
      • 이 때 애플리케이션 코드가 derivation function 역할을 수행.

 

애플리케이션 코드와 상태의 분리
  • “DB는 데이터 저장에, 애플리케이션은 코드 실행에” → 역할 분리가 합리적
  • “상태(state)는 DB에, 로직(logic)은 코드에” → 이것이 오늘날의 기본적인 시스템 구조
  • DB는 “공유 가능한 가변 변수(shared mutable variable)” 처럼 작동
    • 하지만 문제는 DB 변경을 실시간으로 감지(subscribe) 하기 어려움
    • 대부분의 DB는 수동적(polling) 방식: “값이 바뀌었는지”를 주기적으로 쿼리해야 함.
    • 최근의 CDC·Change Stream 기술은 이 간극을 메워, DB와 애플리케이션 간의 진정한 분리가 가능함

 

데이터플로 : 상태 변경과 애플리케이션 코드 간 상호작용
➔ Unbundled Database = Dataflow 시스템으로 재조립된 DB
  • 기존의 관계 — “코드가 상태를 조작한다”
    • 전통적으로 우리는 DB를 단순한 상태 저장소(state holder) 로 봄
    • 애플리케이션이 DB를 읽고 → 로직 처리 후 → DB에 다시 쓴다.
    • 즉, 애플리케이션은 명령어 중심(command-driven) 으로 동작하고,
    • DB는 수동적(passive) 역할에 머문다.
  • 새로운 관점 — “상태 변화와 코드의 상호작용(interplay)”
    • 이제는 DB의 상태 변화 자체를 이벤트(event) 로 보고,
    • 애플리케이션 코드가 그 변화를 구독(subscribe) 하여 반응하는 구조로 변화.
    • 예:
      • 사용자가 주문 생성 → “OrderCreated” 이벤트 발생
      • 코드가 이 이벤트를 받아 → 결제 요청, 재고 차감 등의 후속 상태 변화(trigger)
    • 즉, 하나의 상태 변화가 또 다른 상태 변화를 유도하는 체계적인 데이터 흐름.
  • 역사적·구조적 맥락
    • 이 개념은 “데이터베이스와 스트림은 동등하다” 는 책의 핵심 논의에서 이어진다.
      • DB의 트랜잭션 로그(log)는 곧 이벤트 스트림(event stream)이다.
      • 따라서 로그를 구독하면 DB의 상태 변화를 실시간으로 감지할 수 있다.
    • 비슷한 아이디어가 이미 오래전부터 존재했음:
      • Actor 모델 (메시지 기반 동시성)
      • Tuple space 모델 (프로세스가 공유 상태의 변화에 반응)
      • Triggers & Secondary Indexes (DB 내부의 자동 반응 로직)
  • Unbundling 관점에서 본 Dataflow
    • DB 내부에서만 일어나던 “상태 변화에 대한 반응(trigger)”을 외부 시스템으로 확장하는 것.
    • 예:
      • DB → Kafka (Change Event)
      • Kafka → Elasticsearch (검색 인덱스 업데이트)
      • Kafka → ML Pipeline (모델 업데이트)
      • Kafka → Cache (UI 캐시 리빌드)
    • 이 모든 과정이 데이터플로우(dataflow) 로 연결된다.
    • 즉, “상태 변화 → 코드 반응 → 새로운 상태 생성”의 연쇄.
  • 데이터플로우와 일반 메시징 시스템의 차이점
    • 파생 데이터를 유지하기 위해선 아래 두 가지 조건이 매우 중요함.
      • 순서 보장
      • 내구성 & 내결함성
  • Stream Processor = 현대의 파생 함수 엔진
    • 각 스트림 연산자(operator)는 “데이터 변화”를 입력으로 받아 새로운 상태를 산출하는 “함수” 로 동작.

 

스트림 처리자와 서비스
  • 요즘 개발 스타일 트렌드는
    • 각 기능을 동기 네트워크 요청을 통해 통신하는 서비스의 집합으로 나누는 것
    • 느슨한 연결을 통한 조직적 확장성
  • 새로운 패러다임: Stream-based Dataflow Systems
    • 스트림 처리 시스템도 “작은 단위의 연산(operators)”을 연결하여 큰 시스템을 구성한다는 점에서 마이크로서비스와 유사함.
    • 그러나 핵심 차이는 통신 방식에 있음:
      • Microservice → Request/Response (동기식)
      • Dataflow → Message Stream (비동기식)
    • 즉, “함수 호출”이 아닌 “데이터 흐름(event flow)”으로 상호작용한다.
  • 스트림 기반 접근의 본질: Stream Join
    • 이 접근은 “RPC 호출”을 “스트림 조인(stream join)”으로 대체한 것.
    • 두 이벤트 스트림:
      1. purchase events (구매 이벤트)
      2. exchange rate updates (환율 이벤트)
    • 두 스트림을 시간 기준으로 조인하여 → “구매 시점의 환율”을 결합.
    • 시간 의존성(time-dependence) 주의:
      • 나중에 재처리할 경우 환율이 달라질 수 있음.
      • 따라서 “구매 시점의 환율”을 복원하려면 과거 환율 이벤트를 함께 보관해야 함.
  • 데이터플로우 접근은 “요청 기반(request-driven)” → “이벤트 기반(event-driven)” 으로의 진화다.

 

 

🌀 2-3. 파생 상태 관찰하기

  • Write Path는 즉시 처리(eager), Read Path는 요청 시 처리(lazy) 라는 관점에서 서로 보완 관계를 이룬다.

 

설계 방향 특징 예시
Write-heavy (사전 계산 중심) - 쓰기 시점에 많은 계산 수행- 읽기 시 매우 빠름 검색 인덱스, Materialized View
Read-heavy (요청 시 계산 중심) - 쓰기는 가볍지만- 읽기 시 많은 계산 필요 OLTP 쿼리, 실시간 집계
균형형 - 일부는 사전 계산, 일부는 실시간 계산 캐시 + 비동기 업데이트
  • 파생 데이터(derived dataset)는 “미리 계산할지, 나중에 계산할지” 의 균형점이다.
  • 시스템 설계는 “계산 시점”을 어디에 둘 것인가의 문제다.

 

구체화 뷰와 캐싱
  • 인덱스는 “쓰기 시점에 미리 정리해두는 구조"이므로 읽기 속도를 빠르게 만들지만, 쓰기 오버헤드 존재
  • 현실적 절충: Common Queries Cache
    • 자주 등장하는 쿼리만 미리 계산(cache or materialized view) 해둠.
    • 나머지 쿼리는 기존 인덱스를 이용해 실시간 검색.
    • 이 구조를 “common query cache” 혹은 “materialized view” 라고 부른다.

 

 

 

오프라인 대응 가능한 상태 저장 클라이언트
➔ “만약 클라이언트가 자체적으로 상태(state)를 갖는다면, 서버-클라이언트 구조는 어떻게 달라질까?”
  • 지난 20년간 웹 애플리케이션은 “서버 중심, 클라이언트 무상태" 모델을 기본으로 함.
  • PA(Single Page Application)와 모바일 앱의 등장으로 패러다임이 바뀜
  • 클라이언트가 저장하는 데이터는 서버의 상태(state) 를 부분 복제(partial replica) 한 것.
    • 서버 → 진리의 원본(Source of Truth)
    • 클라이언트 → 구체화 뷰 혹은 캐시
    • 서버의 상태 변경이 클라이언트에 반영될 때까지 동기화 지연(sync delay) 존재함

 

상태 변경을 클라이언트에게 푸시하기
➔ 로컬 상태를 어떻게 서버 상태와 동기화할 것인가
  • 기존의 웹 패턴
    • Polling 기반, Stale Cache
  • 변화 (Push 기반 프로토콜의 등장)
    • Server-Sent Events (SSE), WebSocket 같은 기술이 등장하면서
    • 서버 → 클라이언트 방향의 푸시(push) 통신이 가능해짐.
    • 클라의 로컬 상태는 더 이상 정적 캐시가 아니라,
            서버 이벤트 스트림과 실시간으로 동기화되는 복제 상태가 된다. 
  • 기존에는 Write Path가 서버 내부의 파생 데이터(인덱스, 뷰 등) 까지만 도달
    • 하지만 Write Path가 클라이언트까지 확장됨.
    • 즉, 서버의 변경 이벤트가 클라이언트로 스트림 형태로 전송.
  • 오프라인 상태를 고려한 동기화 전략
    • 클라이언트는 종종 오프라인 상태가 되므로, 서버의 이벤트를 받을 수 없는 시간 구간이 생김.
    • 로그 기반 메시징 시스템(Kafka 등)의 offset 재연결 패턴으로 해결 가능

 

종단 간 이벤트 스트림
  • 현대 데이터 시스템의 미래는 “end-to-end event streams”이다.
  • 즉, 사용자의 입력부터 다른 사용자 화면의 변화까지 하나의 이벤트 스트림으로 연결되는 구조.
  • 이를 위해서는 “stateless request/response” 패러다임을 넘어
          “stateful, publish/subscribe dataflow” 로 전환해야 한다.
  • 데이터를 질의하는 대신 변화를 구독(subscribe)해야 한다.\

 

읽기도 이벤트다
  • 기존 구조: “읽기와 쓰기”의 분리
    • 지금까지의 모델에서는 다음처럼 역할이 나뉨:
      • Write Path: 이벤트 로그를 기반으로 파생 데이터 생성.
      • Read Path: 저장소(DB, Cache, Index)를 쿼리하여 결과 반환.
    • 즉, 읽기(Read)는 한 번의 네트워크 요청, 쓰기(Write)는 이벤트 스트림의 일부로만 다뤄짐.
    • 그러나 저자는 읽기도 이벤트로 볼 수 있음.
  • 새로운 관점: “읽기 요청도 이벤트다”
    • 즉, 읽기 요청(read query)을 하나의 이벤트(read event) 로 표현하고,
    • 이를 스트림 프로세서(stream processor) 가 처리할 수 있음.
    • 새로운 데이터플로우 구조:
      • [Read Request Event Stream] → [Stream Processor] → [Read Result Event Stream]
        • 사용자는 “요청” 이벤트를 발행(publish)
        • 스트림 프로세서가 “결과” 이벤트를 발행(subscribe & process)
        • 이 구조는 요청/응답(request/response) 모델을 pub/sub 패러다임으로 대체함.
  • Stream-Table Join으로서의 “읽기”
    • 쓰기와 읽기를 모두 이벤트로 표현하면,
            “읽기 요청 스트림”과 “데이터 스트림(또는 테이블)”을 조인(join) 하는 형태로 해석할 수 있음.
  • One-off Read vs Subscription Read
    • 일회성 조회(one-off read):
      • 단일 요청을 조인에 통과시켜 결과를 반환하고 종료.
      • 일반적인 “쿼리 1회 실행” 형태.
    • 구독형 조회(subscribe request):
      • 지속적으로 조인 상태를 유지.
      • 데이터가 바뀌면 새 결과를 푸시.
      • 즉, “Reactive Query / Live Query” 모델.
  • 읽기 이벤트를 기록(Log)할 때의 부가 가치
    • 읽기 이벤트를 로그에 남기면,
          데이터 혈통(data provenance) 과 인과관계(causality) 추적이 가능함
    • 예시:
      • 사용자 A가 상품을 봄 → 재고 “있음” 상태 확인 → 구매 결정
      • 그 후 재고가 소진됨
      • 나중에 “왜 구매 버튼을 눌렀는가?”를 분석하려면
        • 사용자가 “당시 어떤 정보를 봤는지” 기록이 필요함 → 읽기 로그
    • 읽기 이벤트를 남기면, 시스템 전체의 원인(Why) 을 재구성할 수 있음.
  • 쓰기와 읽기를 모두 로그로 통합하면…
    • 읽기 이벤트도 로그에 기록하면:
      • 장점: 완전한 인과 추적 가능 (cause & effect)
      • 단점: 저장 공간, I/O 부하 증가
    • 하지만 이미 쓰기 로그를 운영 중이라면, 읽기 로그도 함께 남기는 것은 자연스러운 확장임.

 

다중 파티션 데이터 처리
  • 트위터의 분산 RPC
    • 다중 파티션에 분산된 데이터를 스트림 조합으로 통합
  • 스트림 기반 다중 파티션 쿼리의 장점
    • MPP (Massively Parallel Processing) 데이터베이스도 유사한 일을 함.
      • 쿼리를 DAG(Directed Acyclic Graph)로 분리
      • 각 노드가 병렬로 실행
      • 마지막에 결과를 결합
    • 스트림 프로세서는 이 구조를 이미 기본적으로 내장하고 있음.
      • 따라서 동일한 처리를 실시간으로(streaming) 수행할 수 있음.
  • 실용적 제안
    • 만약 단순히 일회성 쿼리라면, MPP DB를 쓰는 게 더 간단할 수 있음.
    • 하지만 지속적 스트림 기반 처리가 필요하다면, 스트림 프로세서 모델이 훨씬 더 적합함.

 

 

 

 

 

❐ 3. 정확성을 목표로


🌀 3-1. 

데이터 시스템만 믿어서는 “완전하게 안전”하지 않다
  • 트랜잭션 격리 수준이 높고, 직렬화 가능한 DB를 사용한다고 해도
          애플리케이션 레벨의 버그로 인해 데이터 손실/손상은 발생할 수 있다.
  • 예
    • 애플리케이션 버그로 잘못된 값을 UPDATE
    • 삭제하면 안 되는 데이터를 DELETE
            → DB의 직렬화 트랜잭션이 이런 문제를 해결해주지 않는다.
  • 데이터 안전은 DB가 아니라 애플리케이션도 함께 책임져야 한다.

 

Exactly-once 처리의 어려움
  • 메시지 처리 중 오류가 생기면 보통 2가지 선택이 있다.
    • 포기한다 → 데이터 유실
    • 재시도한다 → 실제로는 성공했는데 응답이 끊겨서 재시도하면 중복 처리
  • 이 중 “재시도했을 때도 결과가 한 번만 처리되는 것”이 바로 Exactly-once semantics.
  • 하지만 현실에서는 정말 구현하기 어렵다.

 

Idempotence(멱등성)으로 해결하는 방법
  • 가장 효과적인 방식은 작업 자체를 멱등(idempotent) 하게 만드는 것.
  • 멱등성을 지원하려면 다음 같은 추가 메타데이터가 필요할 수 있다.
    • 요청 ID
    • 실행된 작업 ID 로그
    • fencing token(노드 장애 시 중복 실행 방지) 등

 

Duplicate suppression(중복 억제)가 필요한 이유
  • TCP 같은 네트워크 계층도 중복을 억제해주지만, 이는 단일 TCP 연결 안에서만 의미가 있다.
  • 문제는:
    • 요청이 DB에 전달되고 COMMIT 했는데
    • 클라이언트가 응답을 못 받고 타임아웃 → 다시 요청
  • 이 경우 DB 트랜잭션 레벨에서 duplicate suppression이 되지 않으면 중복 처리 위험이 있다.

 

“고급 트랜잭션 프로토콜”도 완벽하지 않다
  • 2PC 같은 프로토콜은 TCP 연결과 트랜잭션을 분리해주지만,
          여전히 애플리케이션이 중복 요청을 보낼 때까지 막지는 못한다.

 

애플리케이션 레벨에서 Operation ID 추가하기
  • 중복 요청을 확실히 막으려면 애플리케이션에서 end-to-end로 중복을 억제해야 한다.
  • 핵심 아이디어
    • 클라이언트가 request_id(UUID) 를 생성
    • 서버로 POST할 때 함께 보냄
    • DB에서 request_id를 PK/UNIQUE로 사용하여 이미 처리된 요청이면 INSERT가 실패 → 중복 방지

 

End-to-End Argument란 무엇인가?
  • Saltzer, Reed, Clark이 정의한 개념
  • 어떤 기능이 정말로 필요하다면, 시스템의 끝단(end-to-end) 에서 보장해야 한다.
  • 낮은 레벨(TCP, 네트워크, 등)의 보장만으로는 부족하다.
  • 예시
    • Duplicate suppression
      • TCP는 패킷 중복을 해결해줘도 클라이언트 → 서버 → DB 전체에 걸친 중복은 막지 못함.
      • 그러므로 duplicate suppression은 DB까지 end-to-end로 관통해야 한다.
    • 데이터 무결성 체크
      • TCP/Ethernet 체크섬은 전송 중 오류만 잡아낸다
      • 하지만 서버 버그, 디스크 손상은 감지 못함
              → 결국 end-to-end 체크가 필요 (예: 애플리케이션 레벨 체크섬)
    • 암호화
      • WiFi 암호화는 집 안 공격자만 막음
      • 서버 공격자는 못 막음 → TLS처럼 end-to-end 암호화가 필요

 

결론 — 데이터 안전은 결국 애플리케이션 책임도 크다
  • DB가 강력한 트랜잭션을 제공해도 중복 억제, 멱등성, end-to-end 검증 없이는 데이터 손상 가능
  • 트랜잭션은 많은 문제를 “commit or abort”로 추상화하지만 현실의 장애는 더 복잡하다
  • 대규모 분산 환경에서는 애플리케이션 수준의 end-to-end 안전성이 필수

 

 

🌀 3-2. 제약 조건 강제하기

유니크 제약(uniqueness constraint)의 어려움
  • 유저네임, 이메일, 계좌 ID처럼 “하나만 있어야 하는 값”을 보장하는 것은 쉬워 보이지만,
          분산된 환경에서는 매우 어렵다.
  • 예를 들어:
    • 두 개의 노드가 동시에 같은 이메일로 회원가입
    • 두 데이터센터에서 같은 좌석을 동시에 예약
            → 모두 “유일해야 하는 값”이기 때문에 충돌을 해결해야 한다.

 

왜 어려울까? → 유니크 보장은 합의(consensus)를 필요로 하기 때문
  • 여러 노드가 동시에 같은 값을 삽입하려고 할 때,
          시스템은 어느 요청이 승자이며, 나머지는 거절해야 하는지 결정해야 한다.
  • 이는 결국 합의(consensus) 문제다.
  • 가장 간단한 방식:
    • 리더(leader)를 하나 두고, 모든 유니크 판단을 리더가 하게 한다.
  • 문제점:
    • 리더 노드 장애 → 다시 합의 필요
    • 리더 한 개가 처리량 병목이 됨
    • 클라이언트가 지구 반대편 → 높은 지연(latency)

 

파티셔닝 기반 유니크 보장
  • 유니크 조건을 값 기반으로 파티션하면 확장성이 높아진다.\
  • 예
    • 요청 ID를 key로 파티션 → 같은 request_id는 항상 같은 파티션으로 감
    • username을 hash(username) 기준으로 파티션 → 동일 username 충돌은 같은 파티션에서만 처리
  • 즉, 동일 값을 유니크하게 만들려면 같은 파티션에서 처리되도록 라우팅하면 된다.
  • 이 방식의 장점
    • 파티션 별로 독립적 처리 가능 → 확장성 높음
    • 각 파티션에서 순서가 보장되면(total order), 충돌 해결 가능
  • 하지만 단점도 있다
    • 비동기 멀티마스터 복제에서는 불가능(서로 다른 노드가 서로 모르게 같은 값을 허용할 수 있기 때문)\
    • 유니크를 강하게 보장하려면 동기적 합의 또는 파티션 기반의 순차 처리가 필요함.

 

로그 기반 메시징에서 유니크 보장하기
  • Kafka처럼 로그 기반 메시징 시스템을 사용하면 유니크 제약을 비교적 쉽게 강하게 보장할 수 있다.
  • 핵심 이유:
    • 로그는 모든 메시지를 순서대로(total order) 기록
    • 파티션 단위로는 한 스레드가 순서대로 처리
    • 따라서 충돌 상황에서도 “누가 먼저 왔는지” 명확히 판단 가능

 

Multi-partition Request Processing (여러 파티션이 관여하면?)
  • 문제는 여기서부터다. 금전 이체 같은 작업은 여러 파티션을 동시에 건드린다.
  • 전통적인 DB 방식:
    • 3개 파티션이 모두 참여 → 분산 트랜잭션(2PC) 필요
    • 처리량 저하 + 높은 지연 → 문제가 많음

 

 분산 트랜잭션 없이도 “동일한 정확성”을 달성하는 법
  • 2단계 파이프라인 분리 + 로그 기반 처리 + 멱등성 조합이다.
  • 단계 1 — 요청 ID를 기반으로 단일 메시지로 로깅
    • 클라이언트가 송금 요청을 하나의 메시지로 보내고
    • 로그에 append (request_id 파티션)
  • 단계 2 — stream processor가 이 메시지를 읽고, 두 개의 별도 명령을 생성
    • A 계좌 파티션: “A 계좌에서 10 빼세요”
    • B 계좌 파티션: “B 계좌에 10 더하세요”
  • 단계 3 — 두 계좌 파티션 소비자가 각각 명령 적용
    • request_id를 기준으로 중복 제거
    • 명령이 여러 번 와도 멱등 적용 가능
  • 결과:
    • A에서 빠지고 B에서 더하는 작업 모두 exactly once 적용됨
    • 단 분산 트랜잭션 없이도 동일한 정확성 확보

 

장애 상황에서도 안전한 이유
➔ 만약 단계 2의 프로세서가 크래시 한다면?
  • 체크포인트에서 재시작
  • 로그에서 다시 읽고 같은 debit/credit 메시지 생성
  • 하지만 단계 3에서 request_id로 dedupe → 중복 방지
  • 즉,
    • 로그는 append-only
    • 처리 과정은 deterministic
    • 결과는 멱등 → “한 번만 처리된 것처럼” 보장 가능

 

 

🌀 3-3. 적시성과 무결성

Transactions는 원래 “Timely”하다 (즉, Linearizable)
  • ACID 트랜잭션은 다음을 보장한다.
  • Commit이 끝난 직후, 모든 읽기는 그 결과를 본다.
  • 즉, 쓰기(Write) → Commit → Read 가 즉시 반영된다.
  • 이게 바로 선형성

 

하지만 Stream Processing에서는 이게 깨진다
  • Kafka처럼 로그 기반 비동기 파이프라인에서:
    • 클라이언트는 메시지를 전송만 하고
    • 실제 처리는 비동기 파이프라인에서 나중에 일어난다
    • 그래서 “commit하자마자 읽으면 반영된다”는 보장이 없다
  • 즉, 타임라인 상 지연이 생기는 게 기본이다.

 

Timeliness = “업데이트된 최신 상태를 얼마나 빨리 볼 수 있나?”
  • 복제지연(replication lag) 때문에
          내가 조금 전 업데이트한 데이터를 다른 노드에서는 잠시동안 못볼 수 있다.
  • 하지만 몇 초 안에 eventually 반영됨.
  • Timeliness는 결국 “언젠가는 최신 상태가 되게 하는 속성”.

 

CAP의 “Consistency”도 사실 Timeliness 의미에 가깝다
  • Linearizability를 제공하는 시스템 = “강한 최신성 보장 시스템”.
  • 그리고 더 약한 형태도 있다:
    • Read-after-write consistency
    • Monotonic reads 등

 

Integrity = “데이터 자체가 틀리지 않음”
  • Integrity는 timeliness보다 훨씬 더 중요하며 핵심적이다.
  • Integrity는 이런 것을 의미한다:
    • 데이터가 손상되지 않았다 (no corruption)
    • 데이터가 사라지지 않았다 (no data loss)
    • 모순된 상태가 없다 (no contradictory state)
    • 파생 데이터는 원본 데이터와 정확히 일치해야 한다

 

Integrity가 깨지면 “복구가 불가능하다”
  • Timeliness 문제는 시간이 지나면 해결되지만,
  • Integrity 문제는:
    • 기다린다고 해결되지 않는다
    • 복구 작업이 필요하다 (manual repair)
    • 장애가 치명적이다

 

4. Event-driven 시스템은 Timeliness와 Integrity를 “분리”한다
  • ACID 세계에서는 동시에 제공되지만 이벤트 기반 스트림 시스템에서는 재밌는 특징이 있다:
  • Integrity를 보장하면서, Timeliness를 포기할 수 있다.
  • 즉,
    • 파이프라인은 비동기일 수 있고
    • 최신 데이터가 바로 반영되지 않아도
    • Integrity만 보장되면 된다
  • Integrity를 지키는 핵심 기법들
    • Exactly-once / Effectively-once 처리
    • 멱등(idempotent) 연산
    • 중복 제거(deduplication)
    • 요청 ID(request_id)를 end-to-end로 전달
    • 불변 로그(immutable log)에 기반한 재처리
  • 이 조합 덕분에 스트림 시스템은 오류가 나도 정합성을 유지할 수 있다.

 

5. 스트림 시스템에서 Integrity를 보장하는 패턴
  • 모든 write를 단일 메시지로 표현 → 원자적으로 기록
  • 모든 파생 상태는 deterministic function으로 유도
  • request_id를 end-to-end로 전달 → 중복 억제
  • 모든 메시지를 immutable하게 관리

 

6. Loosely interpreted constraints (느슨한 제약 조건)
  • 현실에서는 “유니크 제약”이 흔히 느슨하게 해도 된다
  • 엄격하게 리니어라이저블하게 처리할 필요가 없는 상황 예:
    • 동시에 두 사람이 같은 좌석을 예약 → 한쪽에게 사과하고 다른 좌석 제안
    • 재고 5개인데 6개 주문됨 → 사과하고 환불/지연 안내
    • 호텔 오버부킹(일반적 패턴) → 보상 제공
    • 계정 overdraft → 나중에 수수료 부과
  • 즉,
    • 완벽한 정확성은 비싸다
    • 실제 비즈니스는 사과/보상 프로세스로 수습 가능하다
    • Integrity는 유지하되 Timeliness는 굳이 엄격할 필요가 없다

 

7. Coordination-avoiding Data Systems (조율을 피하는 시스템)
  • 두 가지 관찰:
    • 스트림 시스템은 분산 트랜잭션 없이도 integrity 보장 가능
    • 많은 제약 조건은 임시 위반 가능(나중에 사과해서 해결할 수 있다)
  • 즉, 동기적 조율(synchronous coordination) 없이도 정확성을 확보할 수 있는 시스템을 만들 수 있다.
  • 협업이 필요 없는 대신:
    • 로그 기반 비동기 처리
    • request_id
    • 멱등 처리
    • 재생 가능한 파이프라인
    • 느슨한 timeliness
  • 이런 시스템의 장점
    • 더 높은 가용성
    • 더 높은 성능
    • 지연이 적음
    • 장애 복구에 강함
    • 조율 비용이 거의 없음
  • AWS Dynamo, Cassandra, Kafka 기반 시스템 등이 이 철학을 따른다.

 

 

🌀 3-3. 믿어라 하지만 확인하라.

시스템은 항상 “잘못될 수 있다”
  • 이런 일들은 드물지만 “언젠가는 반드시” 발생한다.
    • 하드웨어 비트 플립
    • 디스크 silent corruption
    • 네트워크 데이터 손상
    • DB 버그
    • 애플리케이션 버그
       
  •  
가장 위험한 태도 = 기술을 맹신하는 것
  • “트랜잭션이니까 데이터는 틀리지 않겠지”
  • “DB가 알아서 무결성 지켜주겠지”

 

해결책 = Auditing & Verification
  • 데이터가 손상됐는지 정기적으로 확인
  • 백업 복원 테스트
  • 해시 기반 데이터 검증
  • 이벤트 로그 기반 재처리
  • end-to-end 검증
  • self-validating/self-auditing 시스템 구축

 

Event sourcing과 로그 기반 시스템은 Auditing에 매우 적합
  • event는 불변
  • deterministic derivation → 재현 가능
  • provenance(데이터의 기원) 추적 용이
  • hash 기반 무결성 검증 가능

 

앞으로의 데이터 시스템은 “Trust, but verify” 철학을 따른다
  • 암호학적 무결성 검증(Merkle Tree 등)
  • 자체 감사 기능(self-audit)
  • 지속적 end-to-end 검증

 

'Book > 데이터 중심 애플리케이션 설계' 카테고리의 다른 글

11. 스트림 처리  (0) 2025.11.01
10장. 일괄 처리(Batch Processing)  (0) 2025.10.26
Part3. 파생 데이터 (Derived Data)  (0) 2025.10.26
9장. 일관성과 합의 (Consistency and Consensus)  (0) 2025.10.17
8장. 분산 시스템의 골칫거리(The Trouble with Distributed Systems)  (0) 2025.10.13
'Book/데이터 중심 애플리케이션 설계' 카테고리의 다른 글
  • 11. 스트림 처리
  • 10장. 일괄 처리(Batch Processing)
  • Part3. 파생 데이터 (Derived Data)
  • 9장. 일관성과 합의 (Consistency and Consensus)
gilbert9172
gilbert9172
gilbert9172 님의 블로그 입니다.
  • gilbert9172
    バックエンド
    gilbert9172
  • 전체
    오늘
    어제
    • All Categories (207)
      • 우테코 7기 (21)
        • 1주차 (8)
        • 2주차 (5)
        • 3주차 (6)
      • Langauge (6)
        • Java (3)
        • Kotlin (3)
      • Back-End (13)
        • SpringBoot (1)
        • Trouble Shooting (0)
        • Setup & Configuration (1)
        • SQL (3)
        • Redis (8)
      • Architecture (6)
        • Multi Module (1)
        • DDD (5)
      • CS (30)
        • Data Structure (6)
        • Operating System (0)
        • Network (12)
        • Database (10)
        • Design Pattern (2)
      • Algorithm (78)
        • 내용 정리 (18)
        • 문제풀이 (60)
      • DevOps (6)
        • AWS (5)
        • Git (1)
      • Front-End (1)
        • Trouble Shooting (1)
      • Project (6)
        • 페이스콕 (6)
      • Book (39)
        • 친절한 SQL 튜닝 (9)
        • 데이터 중심 애플리케이션 설계 (14)
        • 이벤트 기반 마이크로서비스 구축 (6)
        • Spring Batch docs (10)
        • Quartz docs (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Two-Pointer
    오블완
    sliding-window
    greedy
    binarysearch
    Back-Tracking
    부분단조성
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
gilbert9172
12. 데이터 시스템의 미래
상단으로

티스토리툴바