생각 정리...

데이터베이스의 본질을 찾아서: 0과 1에서 시작된 학습

JuNo_12 2025. 6. 22. 15:57

들어가며

안녕하세요. 오늘은 제가 데이터베이스를 공부하면서 느꼈던 깊은 인상과 함께, 데이터베이스가 어떻게 탄생하고 발전해왔는지에 대한 이야기를 나누고자 합니다.


시작은 단순한 호기심이었습니다

처음 백엔드 개발을 시작했을 때, 저는 단순히 "데이터를 어떻게 저장하고 불러올까?"라는 질문에서 출발했습니다. 그런데 공부를 하면 할수록, 데이터베이스라는 것이 단순한 저장소가 아니라 인류가 정보를 다루는 방식의 혁명적 전환점이라는 것을 깨달았습니다.

특히 흥미로웠던 점은, 결국 모든 것이 0과 1로 귀결된다는 사실이었습니다. 제가 작성한 user.setName("JunHo")이라는 한 줄의 코드가 어떻게 디스크의 자기장 패턴으로 변환되는지 생각해보니, 이 모든 과정이 마법처럼 느껴졌습니다.


과거를 돌아보니 현재가 보였습니다

1940-1960년대: 물리적 한계와의 싸움

데이터베이스가 없던 시절을 상상해보신 적이 있으신가요? 1940년대에는 천공카드라는 것을 사용했습니다. 80열 12행의 구멍으로 데이터를 표현했죠. 지금 생각하면 정말 원시적이지만, 당시로서는 혁명적인 기술이었습니다.

 

제가 특히 인상 깊었던 사례는 Bank of America의 ERMA 시스템입니다. 1950년대 이 은행은 계좌가 월 23,000개씩 증가하면서 오후 2시에 영업을 중단해야 했습니다. 2,500명의 직원이 시간당 겨우 245개 계정만 처리할 수 있었거든요. 그런데 ERMA 도입 후? 시간당 33,000개 계정 처리. 무려 134배의 생산성 향상이었습니다.

 

이런 이야기를 읽으면서 저는 생각했습니다. "아, 기술이란 결국 실제 문제를 해결하기 위해 존재하는구나."

 

파일 시스템의 치명적 한계들

당시 시스템들이 직면한 문제는 현재 우리가 겪는 문제와 놀랍도록 유사했습니다:

 

1. 데이터 중복 문제

고객 정보가 여러 파일에 중복 저장:
- 주문 파일: 고객명, 주소, 전화번호
- 청구 파일: 고객명, 주소, 전화번호  
- 재고 파일: 고객명, 주소, 전화번호
→ 30-50%의 저장 공간 낭비

 

2. 프로그램-데이터 의존성

데이터 구조를 조금만 바꿔도 모든 프로그램을 다시 작성해야 했다니... 예를 들어, 80컬럼 카드를 96컬럼으로 바꾸면? 모든 READ/WRITE 문장을 수정해야 했습니다.

이걸 보면서 현재 우리가 당연하게 사용하는 '데이터 독립성'이 얼마나 위대한 개념인지 실감했습니다.


데이터베이스의 탄생: 패러다임의 전환

Edgar F. Codd의 혁명적 아이디어 (1970년)

Codd가 제시한 '데이터 독립성' 개념은 정말 놀라웠습니다:

  • 물리적 데이터 독립성: 하드디스크를 SSD로 바꿔도 애플리케이션 코드는 그대로!
  • 논리적 데이터 독립성: 테이블 구조를 변경해도 비즈니스 로직은 영향받지 않음

이게 바로 제가 현재 JPA를 사용하면서 누리는 혜택의 근원이었던 거죠.

 

추상화의 3단계 구조

데이터베이스를 공부하면서 가장 아름답다고 느낀 개념입니다:

  1. 물리적 수준: 데이터가 실제로 어떻게 저장되는가?
  2. 논리적 수준: 데이터 간의 관계는 어떻게 구성되는가?
  3. 뷰 수준: 사용자에게는 어떻게 보여줄 것인가?

마치 건물을 짓는 것과 같습니다. 기초공사(물리적), 설계도(논리적), 인테리어(뷰)처럼 각 층이 독립적으로 변경 가능한 구조죠.


RDBMS: 관계형 모델의 승리

관계형 데이터베이스 관리 시스템(RDBMS)의 핵심 (도메인별 순수 분리)

Codd의 이론이 실제로 구현된 것이 바로 RDBMS입니다. 제가 RDBMS를 공부하면서 가장 인상 깊었던 특징들을 정리해보면:

 

1. 각 도메인별 독립적인 테이블 설계

-- 사용자 도메인
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    age INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 주문 도메인 (User 테이블과 물리적으로는 연결되지만 논리적으로는 독립)
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,  -- 참조만 할 뿐, JOIN은 애플리케이션에서
    total_amount DECIMAL(10,2) NOT NULL,
    status ENUM('PENDING', 'COMPLETED', 'CANCELLED') DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_id (user_id),
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 상품 도메인
CREATE TABLE products (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    category_id BIGINT,
    specifications JSON,  -- 유연한 속성은 JSON으로
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_category (category_id),
    INDEX idx_price (price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

 

2. 도메인별 순수한 Repository

// User 도메인은 User 테이블만 관심
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    Optional<User> findByEmail(String email);
    
    List<User> findByAgeGreaterThan(int age);
    
    @Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
    List<User> findByNameContaining(@Param("name") String name);
    
    // User 도메인 내부 로직만 처리
    @Modifying
    @Query("UPDATE User u SET u.lastLoginAt = CURRENT_TIMESTAMP WHERE u.id = :id")
    void updateLastLoginTime(@Param("id") Long id);
}

// Order 도메인은 Order 테이블만 관심  
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // userId로 조회는 하지만 User 객체는 가져오지 않음
    List<Order> findByUserId(Long userId);
    
    List<Order> findByStatus(OrderStatus status);
    
    @Query("SELECT o FROM Order o WHERE o.createdAt >= :startDate AND o.status = :status")
    List<Order> findRecentOrdersByStatus(@Param("startDate") LocalDateTime startDate, 
                                       @Param("status") OrderStatus status);
}

 

애플리케이션 서비스에서 도메인 조합

 

1. 읽기와 쓰기 분리 접근

// 명령(쓰기)용: 각 도메인 독립적으로 처리
@Service
@Transactional
public class OrderCommandService {
    
    private final OrderRepository orderRepository;
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    
    public Long createOrder(CreateOrderCommand command) {
        // 1. 각 도메인에서 독립적으로 검증
        User user = userRepository.findById(command.getUserId())
            .orElseThrow(() -> new UserNotFoundException());
            
        Product product = productRepository.findById(command.getProductId())
            .orElseThrow(() -> new ProductNotFoundException());
        
        // 2. Order 도메인 로직 처리
        Order order = Order.builder()
            .userId(user.getId())  // 객체가 아닌 ID만 저장
            .productId(product.getId())
            .quantity(command.getQuantity())
            .totalAmount(product.getPrice().multiply(BigDecimal.valueOf(command.getQuantity())))
            .build();
            
        Order savedOrder = orderRepository.save(order);
        
        // 3. 도메인 이벤트 발행 (결합도 낮춤)
        eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder.getId(), user.getId()));
        
        return savedOrder.getId();
    }
}

// 조회(읽기)용: 성능 최적화된 별도 서비스
@Service
@Transactional(readOnly = true)
public class OrderQueryService {
    
    private final JdbcTemplate jdbcTemplate;
    
    // 화면 표시용 데이터는 효율적으로 한 번에 조회
    public List<OrderSummaryDto> getOrderSummaryByUser(Long userId) {
        String sql = """
            SELECT 
                o.id as order_id,
                o.total_amount,
                o.status,
                o.created_at,
                p.name as product_name,
                p.price
            FROM orders o 
            JOIN products p ON o.product_id = p.id 
            WHERE o.user_id = ?
            ORDER BY o.created_at DESC
            """;
            
        return jdbcTemplate.query(sql, 
            (rs, rowNum) -> OrderSummaryDto.builder()
                .orderId(rs.getLong("order_id"))
                .totalAmount(rs.getBigDecimal("total_amount"))
                .status(rs.getString("status"))
                .createdAt(rs.getTimestamp("created_at").toLocalDateTime())
                .productName(rs.getString("product_name"))
                .productPrice(rs.getBigDecimal("price"))
                .build(),
            userId);
    }
}

 

2. 도메인 이벤트로 결합도 낮추기

// 이벤트를 통한 크로스 도메인 처리
@Component
public class OrderEventHandler {
    
    private final UserRepository userRepository;
    
    @EventListener
    @Transactional
    public void handleOrderCreated(OrderCreatedEvent event) {
        // User 도메인에서 독립적으로 포인트 적립 처리
        User user = userRepository.findById(event.getUserId())
            .orElseThrow(() -> new UserNotFoundException());
            
        user.addPoints(100); // User 도메인 로직
        userRepository.save(user);
    }
    
    @EventListener
    @Transactional  
    public void handleOrderCompleted(OrderCompletedEvent event) {
        // 주문 완료 시 사용자 등급 업데이트
        User user = userRepository.findById(event.getUserId())
            .orElseThrow(() -> new UserNotFoundException());
            
        user.updateGradeIfNeeded(); // User 도메인 로직
        userRepository.save(user);
    }
}

 

MySQL 활용한 최적화 기법

 

1. JSON 컬럼 활용 (단일 도메인 내에서)

-- 상품 도메인 내에서 유연한 스펙 관리
INSERT INTO products (name, price, specifications) VALUES 
('아이폰15', 1200000, JSON_OBJECT(
    'color', '블루',
    'storage', '128GB', 
    'camera', JSON_OBJECT('main', '48MP', 'front', '12MP'),
    'features', JSON_ARRAY('Face ID', '무선충전', '방수')
));

-- JSON 검색 최적화 (Generated Column + Index)
ALTER TABLE products 
ADD COLUMN color VARCHAR(50) AS (JSON_UNQUOTE(JSON_EXTRACT(specifications, '$.color'))) STORED,
ADD INDEX idx_color (color);

-- 빠른 색상별 검색
SELECT name, price FROM products WHERE color = '블루';

 

2. 도메인별 인덱스 전략

-- User 도메인: 이메일 검색 최적화
CREATE UNIQUE INDEX idx_user_email ON users(email);
CREATE INDEX idx_user_age_created ON users(age, created_at);

-- Order 도메인: 사용자별 + 상태별 검색 최적화  
CREATE INDEX idx_order_user_status ON orders(user_id, status, created_at);
CREATE INDEX idx_order_date_amount ON orders(created_at, total_amount);

-- Product 도메인: 카테고리 + 가격 검색 최적화
CREATE INDEX idx_product_category_price ON products(category_id, price);

 

3. 대용량 데이터 파티셔닝 (도메인별)

-- 주문 도메인: 날짜별 파티셔닝으로 성능 향상
CREATE TABLE orders_partitioned (
    id BIGINT AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    total_amount DECIMAL(10,2),
    status ENUM('PENDING', 'COMPLETED', 'CANCELLED'),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, created_at)
) ENGINE=InnoDB
PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION pmax VALUES LESS THAN MAXVALUE
);

 

타협하는 지점들

 

1. 읽기 전용 복합 뷰 서비스

// 복잡한 리포팅은 별도 서비스로 분리
@Service
@Transactional(readOnly = true)
public class ReportQueryService {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    // 관리자용 대시보드 - 성능 우선으로 네이티브 쿼리 사용
    public List<UserOrderStatsDto> getUserOrderStats(LocalDateTime startDate) {
        return entityManager.createNativeQuery("""
            SELECT 
                u.id as user_id,
                u.name as user_name,
                u.email,
                COUNT(o.id) as order_count,
                COALESCE(SUM(o.total_amount), 0) as total_spent,
                AVG(o.total_amount) as avg_order_amount
            FROM users u 
            LEFT JOIN orders o ON u.id = o.user_id 
                AND o.created_at >= :startDate 
                AND o.status = 'COMPLETED'
            GROUP BY u.id, u.name, u.email
            ORDER BY total_spent DESC
            """, "UserOrderStatsMapping")
            .setParameter("startDate", startDate)
            .getResultList();
    }
}

// DTO 매핑 설정
@SqlResultSetMapping(
    name = "UserOrderStatsMapping",
    classes = @ConstructorResult(
        targetClass = UserOrderStatsDto.class,
        columns = {
            @ColumnResult(name = "user_id", type = Long.class),
            @ColumnResult(name = "user_name", type = String.class),
            @ColumnResult(name = "email", type = String.class),
            @ColumnResult(name = "order_count", type = Long.class),
            @ColumnResult(name = "total_spent", type = BigDecimal.class),
            @ColumnResult(name = "avg_order_amount", type = BigDecimal.class)
        }
    )
)
@Entity
public class UserOrderStatsDto {
    // 생성자와 getter들...
}

 

2. 배치 처리에서의 효율적 접근

// 배치에서는 성능을 위해 bulk 연산 허용
@Service
public class MonthlyReportBatchService {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional
    public void generateMonthlyUserStats() {
        // 월별 사용자 통계 배치 생성 (bulk insert)
        String sql = """
            INSERT INTO user_monthly_stats (user_id, month, order_count, total_amount)
            SELECT 
                o.user_id,
                DATE_FORMAT(o.created_at, '%Y-%m') as month,
                COUNT(*) as order_count,
                SUM(o.total_amount) as total_amount
            FROM orders o 
            WHERE o.status = 'COMPLETED'
                AND o.created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 1 MONTH)
            GROUP BY o.user_id, DATE_FORMAT(o.created_at, '%Y-%m')
            """;
            
        entityManager.createNativeQuery(sql).executeUpdate();
    }
}

 

3. 캐싱 전략 (도메인별)

// 각 도메인별 독립적인 캐싱
@Service
public class UserCacheService {
    
    private final UserRepository userRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    
    @Cacheable(value = "users", key = "#userId")
    public User getUser(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException());
    }
    
    @CacheEvict(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }
}

@Service  
public class ProductCacheService {
    
    private final ProductRepository productRepository;
    
    @Cacheable(value = "products", key = "#categoryId")
    public List<Product> getProductsByCategory(Long categoryId) {
        return productRepository.findByCategoryId(categoryId);
    }
    
    // JSON 스펙 검색 결과도 캐싱
    @Cacheable(value = "products_by_brand", key = "#brand")
    public List<Product> getProductsByBrand(String brand) {
        return productRepository.findByBrand(brand);
    }
}

 

JPA 최적화 팁 (도메인 경계 준수)

 

1. N+1 문제 해결 (도메인 내에서만)

// 잘못된 예: 다른 도메인까지 fetch join
// @Query("SELECT u FROM User u JOIN FETCH u.orders") // 도메인 경계 위반

// 올바른 예: 각 도메인에서 독립적으로 해결
@Service
public class UserOrderService {
    
    public UserWithOrdersDto getUserWithOrders(Long userId) {
        // 1. User 조회
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException());
            
        // 2. Orders 별도 조회 (배치 크기 최적화)
        List<Order> orders = orderRepository.findByUserId(userId);
        
        return UserWithOrdersDto.of(user, orders);
    }
    
    // 여러 사용자의 주문을 효율적으로 조회
    public List<UserWithOrdersDto> getUsersWithOrders(List<Long> userIds) {
        // 1. Users 일괄 조회
        List<User> users = userRepository.findAllById(userIds);
        
        // 2. Orders 일괄 조회  
        List<Order> orders = orderRepository.findByUserIdIn(userIds);
        
        // 3. 메모리에서 그룹핑
        Map<Long, List<Order>> ordersByUserId = orders.stream()
            .collect(groupingBy(Order::getUserId));
            
        return users.stream()
            .map(user -> UserWithOrdersDto.of(user, ordersByUserId.get(user.getId())))
            .collect(toList());
    }
}

 

2. 배치 크기 최적화

// application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50  # 도메인별 배치 크기 조정
        order_inserts: true
        order_updates: true
        batch_versioned_data: true

// 대량 insert 시 도메인별 최적화
@Service
@Transactional
public class BulkInsertService {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public void bulkInsertProducts(List<CreateProductRequest> requests) {
        int batchSize = 50;
        
        for (int i = 0; i < requests.size(); i++) {
            Product product = Product.from(requests.get(i));
            entityManager.persist(product);
            
            if (i % batchSize == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
}

 

마무리: 가이드라인

원칙

  1. 쓰기(Command): 도메인 경계 엄격 준수
    • 각 Repository는 자신의 도메인만 담당
    • 크로스 도메인 로직은 애플리케이션 서비스에서 조합
    • 도메인 이벤트로 결합도 낮춤
  2. 읽기(Query): 성능 고려해서 유연하게
    • 화면 표시용 조회는 별도 Query Service
    • 복잡한 리포팅은 네이티브 쿼리 허용
    • 배치 처리에서는 효율성 우선
  3. MySQL 활용: 각 도메인의 특성에 맞게
    • JSON 컬럼으로 유연한 스키마 (단일 도메인 내)
    • 인덱스와 파티셔닝으로 성능 최적화
    • 캐싱 전략도 도메인별로 독립 적용

 

현실적 타협점

  • 도메인 순수성: 비즈니스 로직과 명령 처리
  • 성능 최적화: 조회와 리포팅
  • 운영 효율성: 배치와 대량 처리

 

RDBMS의 놀라운 성공 스토리

1980년대부터 현재까지 RDBMS가 데이터베이스 시장을 지배하게 된 이유는 무엇일까요?

 

1. 수학적 기반의 견고함 관계 대수와 관계 해석이라는 탄탄한 이론적 기반 덕분에 예측 가능하고 안정적인 동작을 보장했습니다.

2. SQL의 표준화 1986년 SQL이 ANSI 표준이 되면서, 개발자들이 벤더에 종속되지 않고 지식을 재사용할 수 있게 되었습니다.

3. 실제 비즈니스 요구사항과의 일치 대부분의 비즈니스 데이터가 정형화된 구조를 가지고 있어, 테이블 형태가 자연스러웠습니다.


ORDBMS: 두 세계의 만남

객체-관계형 데이터베이스의 등장 배경

1990년대에 접어들면서 객체지향 프로그래밍이 대세가 되었습니다. 그런데 문제가 있었죠. 그건 바로 "임피던스 미스매치(Impedance Mismatch)" 문제입니다:

// Java 코드: 객체는 상속 관계를 가짐
class Person {
    String name;
    int age;
}

class Student extends Person {
    String studentId;
    double gpa;
}

// SQL: 테이블은 상속이 없음
-- 어떻게 표현하지?

이런 문제를 해결하기 위해 ORDBMS(Object-Relational DBMS)가 등장했습니다.

 

ORDBMS의 주요 특징

1. 사용자 정의 타입(UDT)

-- PostgreSQL 예제
CREATE TYPE address AS (
    street VARCHAR(100),
    city VARCHAR(50),
    zipcode VARCHAR(10)
);

CREATE TABLE person (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    home_address address
);

 

2. 상속 지원

-- PostgreSQL의 테이블 상속
CREATE TABLE person (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

CREATE TABLE student (
    student_id VARCHAR(20),
    gpa DECIMAL(3,2)
) INHERITS (person);

 

3. 메소드와 함수 정의

-- Oracle의 객체 타입 예제
CREATE TYPE person_type AS OBJECT (
    name VARCHAR2(100),
    age NUMBER,
    MEMBER FUNCTION get_age_group RETURN VARCHAR2
);

 

ORDBMS vs 순수 OODBMS

제가 공부하면서 흥미로웠던 점은, ORDBMS가 순수한 객체지향 데이터베이스(OODBMS)보다 더 성공적이었다는 것입니다:

 

ORDBMS의 장점:

  • 기존 RDBMS의 모든 장점 유지
  • SQL 호환성 보장
  • 점진적 마이그레이션 가능

OODBMS의 한계:

  • 표준 쿼리 언어 부재
  • 기존 시스템과의 호환성 문제
  • 제한적인 벤더 지원

 

현재 ORDBMS 활용 사례

 

PostgreSQL: 가장 성공적인 오픈소스 ORDBMS

-- JSON 타입 지원 (객체를 직접 저장)
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    attributes JSONB
);

INSERT INTO products (name, attributes) VALUES 
('Laptop', '{"cpu": "i7", "ram": "16GB", "storage": {"type": "SSD", "size": "512GB"}}');

-- JSON 내부 필드로 쿼리
SELECT * FROM products 
WHERE attributes->>'cpu' = 'i7';

 

Oracle: 엔터프라이즈 환경의 강자

-- Oracle의 객체 타입과 중첩 테이블
CREATE TYPE phone_type AS OBJECT (
    type VARCHAR2(10),
    number VARCHAR2(20)
);

CREATE TYPE phone_list AS TABLE OF phone_type;

CREATE TABLE contacts (
    id NUMBER PRIMARY KEY,
    name VARCHAR2(100),
    phones phone_list
) NESTED TABLE phones STORE AS phones_table;

현재: 우리가 직면한 도전과제

여전히 반복되는 역사

재미있는 건, 과거의 문제들이 형태만 바꿔서 여전히 존재한다는 것입니다:

 

과거 vs 현재의 데이터 일관성 문제

  • 과거: SABRE 항공 예약 시스템의 동일 좌석 중복 예약
  • 현재: 분산 데이터베이스의 CAP 이론 (일관성 vs 가용성)

과거 vs 현재의 성능 문제

  • 과거: 테이프 끝부분 데이터 검색에 24분 소요
  • 현재: 페타바이트 데이터에서 실시간 분석 요구

 

폴리글랏 퍼시스턴스 시대

제가 정말 흥미롭게 보고 있는 현상은, 이제는 하나의 데이터베이스로 모든 문제를 해결하려 하지 않는다는 것입니다.

 

실제 기업들의 사례를 보면:

  • 넷플릭스: Cassandra(로깅) + MySQL(계정) + Neo4j(추천)
  • 우버: MySQL(트랜잭션) + Redis(실시간) + Cassandra(로그)

각 데이터의 특성에 맞는 최적의 도구를 선택하는 것, 이게 현대적 접근이라고 생각합니다.


RDBMS/ORDBMS와 NoSQL의 공존

적재적소의 선택

제가 학습을 진행하면서 깨달은 것은, RDBMS와 NoSQL은 경쟁 관계가 아니라 보완 관계라는 것입니다.

 

RDBMS/ORDBMS가 적합한 경우:

  • 정형화된 비즈니스 데이터 (주문, 결제, 회원)
  • ACID가 필수인 금융 트랜잭션
  • 복잡한 조인과 집계가 필요한 리포팅

NoSQL이 적합한 경우:

  • 대용량 로그 데이터
  • 실시간 세션 관리
  • 유연한 스키마가 필요한 콘텐츠

 

하이브리드 접근의 실제

제가 예시로 작성한 하이브리드 구조입니다:

// 핵심 비즈니스 로직: PostgreSQL (ORDBMS)
const user = await db.users.findOne({ id: userId });

// 세션 관리: Redis
await redis.set(`session:${sessionId}`, JSON.stringify(userData));

// 사용자 활동 로그: MongoDB
await mongoDb.activities.insertOne({
    userId,
    action: 'purchase',
    timestamp: new Date(),
    metadata: { /* 유연한 구조 */ }
});

바로 활용할 수 있는 인사이트

1. 데이터 모델링할 때 꼭 기억하자

정규화의 중요성

  • 1NF~3NF까지만 제대로 이해해도 대부분의 중복 문제 해결
  • 하지만 과도한 정규화는 조인 지옥을 만듭니다.

인덱스 설계

-- 복합 인덱스는 순서가 중요합니다
CREATE INDEX idx_user_date ON orders(user_id, created_at);
-- WHERE user_id = ? (O)
-- WHERE created_at = ? (X)

 

2. JPA 사용 시 주의사항

N+1 문제는 정말 흔합니다

// 이렇게 하면 N+1 발생
List<User> users = userRepository.findAll();
for(User user : users) {
    user.getOrders().size(); // 각 유저마다 쿼리 발생!
}

// 해결책: fetch join
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();

 

3. ORDBMS 기능 활용하기

PostgreSQL의 JSON 활용

-- 유연한 스키마와 정형 데이터의 결합
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    -- 가변적인 속성은 JSON으로
    specifications JSONB
);

-- GIN 인덱스로 JSON 검색 성능 향상
CREATE INDEX idx_specs ON products USING GIN (specifications);

 


마무리: 0과 1

결국 모든 데이터는 0과 1로 표현됩니다. 하지만 그 단순함 위에 우리는 놀라운 추상화의 탑을 쌓아올렸습니다.

개발자: user.setName("John")
   ↓ (JPA - 객체 관계 매핑)
   ↓ (JDBC - SQL 생성)
   ↓ (RDBMS/ORDBMS - 쿼리 최적화)
   ↓ (스토리지 엔진 - 물리적 저장)
디스크: 01001010 01101111 01101000 01101110

 

이 과정을 이해하는 것, 그리고 각 계층의 역할을 아는 것이 진정한 백엔드 개발자가 되는 길이라고 생각합니다.

 

데이터베이스는 단순한 저장소가 아닙니다. 그것은 인류가 정보를 다루는 방식의 진화이며, 추상화와 독립성이라는 위대한 아이디어의 구현입니다. RDBMS는 이 아이디어의 첫 번째 성공적 구현이었고, ORDBMS는 객체지향 패러다임과의 조화를 이뤘으며, NoSQL은 새로운 요구사항에 대한 답이었습니다.

 

앞으로도 데이터베이스 기술은 계속 발전할 것입니다. 하지만 그 근본에는 여전히 "복잡성을 숨기고 단순함을 제공한다"는 철학이 자리할 것이라 생각합니다.

 

여러분도 데이터베이스를 공부하실 때, 단순히 사용법만 익히지 마시고 그 뒤에 숨은 철학과 역사를 함께 공부해보시면 많은 도움이 될거라고 확신합니다. 그러면 왜 이런 기술이 필요한지, 어떤 문제를 해결하려 했는지 이해하게 되고, 더 나은 개발자가 될 수 있을 거라 믿습니다.

 

긴 글 읽어주셔서 감사합니다.