728x90

마이크로서비스 아키텍처(MSA)에서 각 서비스는 독립적으로 운영되지만, 사용자에게 일관된 알림 경험을 제공해야 합니다. 주문 완료, 결제 승인, 배송 시작 등 다양한 이벤트가 발생할 때마다 이메일, SMS, 푸시 알림을 통해 사용자에게 적절한 정보를 전달해야 하죠.

이번 글에서는 Spring Boot와 MySQL을 사용하여 MSA 환경에서 효율적인 알림 서비스를 설계하고 구현하는 방법을 살펴보겠습니다.

예제 시나리오

온라인 쇼핑몰의 다음 서비스들이 알림을 발송해야 하는 상황을 가정하겠습니다:

  • 주문 서비스: 주문 완료, 주문 취소 알림
  • 결제 서비스: 결제 완료, 결제 실패 알림
  • 배송 서비스: 배송 시작, 배송 완료 알림
  • 회원 서비스: 회원 가입 환영, 비밀번호 변경 알림

알림 서비스 아키텍처

전체 구조도

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Order Service │    │ Payment Service │    │Shipping Service │
│                 │    │                 │    │                 │
│  ┌──────────┐   │    │  ┌──────────┐   │    │  ┌──────────┐   │
│  │Event Pub │   │    │  │Event Pub │   │    │  │Event Pub │   │
│  └──────────┘   │    │  └──────────┘   │    │  └──────────┘   │
└─────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
         └───────────────────────┼───────────────────────┘
                                 │
                    ┌─────────────────────────────┐
                    │      Message Queue          │
                    │        (RabbitMQ)           │
                    └─────────────────────────────┘
                                 │
                    ┌─────────────────────────────┐
                    │   Notification Service      │
                    │                             │
                    │  ┌─────────────────────┐    │
                    │  │  Event Processor    │    │
                    │  └─────────────────────┘    │
                    │  ┌─────────────────────┐    │
                    │  │  Template Engine    │    │
                    │  └─────────────────────┘    │
                    │  ┌─────────────────────┐    │
                    │  │  Channel Router     │    │
                    │  └─────────────────────┘    │
                    └─────────────────────────────┘
                                 │
            ┌────────────────────┼────────────────────┐
            │                    │                    │
    ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
    │ Email Service │   │  SMS Service  │   │ Push Service  │
    │   (SMTP)      │   │   (Twilio)    │   │   (FCM)       │
    └───────────────┘   └───────────────┘   └───────────────┘

시퀀스 다이어그램

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│Order Service│ │Message Queue│ │Notification │ │ Template    │ │Email Service│
│             │ │             │ │   Service   │ │   Engine    │ │             │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
       │                │                │                │                │
       │ 1. Publish     │                │                │                │
       │ OrderComplete  │                │                │                │
       │ Event         │                │                │                │
       │──────────────→│                │                │                │
       │                │                │                │                │
       │                │ 2. Consume     │                │                │
       │                │ Event         │                │                │
       │                │──────────────→│                │                │
       │                │                │                │                │
       │                │                │ 3. Load        │                │
       │                │                │ Template      │                │
       │                │                │──────────────→│                │
       │                │                │                │                │
       │                │                │ 4. Generate    │                │
       │                │                │ Content       │                │
       │                │                │←──────────────│                │
       │                │                │                │                │
       │                │                │ 5. Send        │                │
       │                │                │ Notification  │                │
       │                │                │──────────────────────────────→│
       │                │                │                │                │
       │                │                │ 6. Update      │                │
       │                │                │ Status        │                │
       │                │                │──────────────→│                │

데이터베이스 설계

알림 관련 테이블 구조

-- 알림 템플릿 테이블
CREATE TABLE notification_template (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    template_code VARCHAR(50) NOT NULL UNIQUE,
    template_name VARCHAR(100) NOT NULL,
    channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
    subject VARCHAR(200),
    content TEXT NOT NULL,
    variables JSON,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 알림 요청 테이블
CREATE TABLE notification_request (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    user_id BIGINT NOT NULL,
    template_code VARCHAR(50) NOT NULL,
    channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
    recipient VARCHAR(200) NOT NULL,
    variables JSON,
    status ENUM('PENDING', 'PROCESSING', 'SENT', 'FAILED') DEFAULT 'PENDING',
    retry_count INT DEFAULT 0,
    error_message TEXT,
    sent_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    INDEX idx_status_created (status, created_at),
    INDEX idx_user_id (user_id),
    INDEX idx_event_type (event_type)
);

-- 알림 발송 이력 테이블
CREATE TABLE notification_history (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    request_id BIGINT NOT NULL,
    channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
    recipient VARCHAR(200) NOT NULL,
    subject VARCHAR(200),
    content TEXT,
    status ENUM('SENT', 'FAILED') NOT NULL,
    provider VARCHAR(50),
    external_id VARCHAR(100),
    error_message TEXT,
    sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    FOREIGN KEY (request_id) REFERENCES notification_request(id),
    INDEX idx_request_id (request_id),
    INDEX idx_sent_at (sent_at)
);

-- 사용자 알림 설정 테이블
CREATE TABLE user_notification_preference (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
    is_enabled BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_user_event_channel (user_id, event_type, channel_type),
    INDEX idx_user_id (user_id)
);

Spring Boot 알림 서비스 구현

1. 도메인 모델 정의

// 알림 요청 엔티티
@Entity
@Table(name = "notification_request")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationRequest {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "event_id", nullable = false)
    private String eventId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

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

    @Column(name = "template_code", nullable = false)
    private String templateCode;

    @Enumerated(EnumType.STRING)
    @Column(name = "channel_type", nullable = false)
    private ChannelType channelType;

    @Column(name = "recipient", nullable = false)
    private String recipient;

    @Convert(converter = JpaConverterJson.class)
    @Column(name = "variables", columnDefinition = "JSON")
    private Map<String, Object> variables;

    @Enumerated(EnumType.STRING)
    @Column(name = "status")
    private NotificationStatus status = NotificationStatus.PENDING;

    @Column(name = "retry_count")
    private Integer retryCount = 0;

    @Column(name = "error_message")
    private String errorMessage;

    @Column(name = "sent_at")
    private LocalDateTime sentAt;

    @CreatedDate
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

// 알림 템플릿 엔티티
@Entity
@Table(name = "notification_template")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationTemplate {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "template_code", unique = true, nullable = false)
    private String templateCode;

    @Column(name = "template_name", nullable = false)
    private String templateName;

    @Enumerated(EnumType.STRING)
    @Column(name = "channel_type", nullable = false)
    private ChannelType channelType;

    @Column(name = "subject")
    private String subject;

    @Column(name = "content", nullable = false, columnDefinition = "TEXT")
    private String content;

    @Convert(converter = JpaConverterJson.class)
    @Column(name = "variables", columnDefinition = "JSON")
    private List<String> variables;

    @Column(name = "is_active")
    private Boolean isActive = true;

    @CreatedDate
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

// 열거형 정의
public enum ChannelType {
    EMAIL, SMS, PUSH
}

public enum NotificationStatus {
    PENDING, PROCESSING, SENT, FAILED
}

2. 이벤트 메시지 정의

// 기본 알림 이벤트
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationEvent {
    private String eventId;
    private String eventType;
    private Long userId;
    private String userEmail;
    private String userPhone;
    private Map<String, Object> data;
    private List<ChannelType> channels;
    private LocalDateTime occurredAt;
}

// 주문 완료 이벤트
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderCompletedEvent {
    private String orderId;
    private Long userId;
    private String userEmail;
    private String customerName;
    private String productName;
    private BigDecimal totalAmount;
    private String orderDate;
    private String deliveryAddress;
    private LocalDateTime occurredAt;

    public NotificationEvent toNotificationEvent() {
        Map<String, Object> data = Map.of(
            "orderId", orderId,
            "customerName", customerName,
            "productName", productName,
            "totalAmount", totalAmount,
            "orderDate", orderDate,
            "deliveryAddress", deliveryAddress
        );

        return NotificationEvent.builder()
            .eventId(orderId)
            .eventType("ORDER_COMPLETED")
            .userId(userId)
            .userEmail(userEmail)
            .data(data)
            .channels(Arrays.asList(ChannelType.EMAIL, ChannelType.SMS))
            .occurredAt(occurredAt)
            .build();
    }
}

3. 메시지 큐 설정

@Configuration
@EnableRabbit
public class RabbitMQConfig {

    public static final String NOTIFICATION_EXCHANGE = "notification.exchange";
    public static final String NOTIFICATION_QUEUE = "notification.queue";
    public static final String NOTIFICATION_ROUTING_KEY = "notification.event";

    @Bean
    public TopicExchange notificationExchange() {
        return new TopicExchange(NOTIFICATION_EXCHANGE);
    }

    @Bean
    public Queue notificationQueue() {
        return QueueBuilder.durable(NOTIFICATION_QUEUE)
            .withArgument("x-dead-letter-exchange", NOTIFICATION_EXCHANGE + ".dlx")
            .withArgument("x-dead-letter-routing-key", "notification.failed")
            .build();
    }

    @Bean
    public Binding notificationBinding() {
        return BindingBuilder
            .bind(notificationQueue())
            .to(notificationExchange())
            .with(NOTIFICATION_ROUTING_KEY);
    }

    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(new Jackson2JsonMessageConverter());
        template.setExchange(NOTIFICATION_EXCHANGE);
        return template;
    }
}

4. 알림 이벤트 발행자 (각 서비스에서 사용)

@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationEventPublisher {

    private final RabbitTemplate rabbitTemplate;

    public void publishOrderCompleted(OrderCompletedEvent event) {
        try {
            NotificationEvent notificationEvent = event.toNotificationEvent();
            rabbitTemplate.convertAndSend(
                RabbitMQConfig.NOTIFICATION_ROUTING_KEY,
                notificationEvent
            );
            log.info("Order completed event published: {}", event.getOrderId());
        } catch (Exception e) {
            log.error("Failed to publish order completed event: {}", event.getOrderId(), e);
        }
    }

    public void publishPaymentCompleted(PaymentCompletedEvent event) {
        try {
            NotificationEvent notificationEvent = event.toNotificationEvent();
            rabbitTemplate.convertAndSend(
                RabbitMQConfig.NOTIFICATION_ROUTING_KEY,
                notificationEvent
            );
            log.info("Payment completed event published: {}", event.getPaymentId());
        } catch (Exception e) {
            log.error("Failed to publish payment completed event: {}", event.getPaymentId(), e);
        }
    }
}

5. 알림 서비스 핵심 구현

// 알림 이벤트 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationEventListener {

    private final NotificationProcessingService notificationProcessingService;

    @RabbitListener(queues = RabbitMQConfig.NOTIFICATION_QUEUE)
    public void handleNotificationEvent(NotificationEvent event) {
        try {
            log.info("Received notification event: {} for user: {}", 
                event.getEventType(), event.getUserId());

            notificationProcessingService.processNotification(event);

        } catch (Exception e) {
            log.error("Failed to process notification event: {}", event.getEventId(), e);
            throw new AmqpRejectAndDontRequeueException("Failed to process notification", e);
        }
    }
}

// 알림 처리 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationProcessingService {

    private final NotificationRequestRepository notificationRequestRepository;
    private final NotificationTemplateRepository templateRepository;
    private final UserNotificationPreferenceRepository preferenceRepository;
    private final NotificationChannelRouter channelRouter;

    @Transactional
    public void processNotification(NotificationEvent event) {
        // 1. 사용자 알림 설정 확인
        List<UserNotificationPreference> preferences = 
            preferenceRepository.findByUserIdAndEventType(event.getUserId(), event.getEventType());

        Map<ChannelType, Boolean> enabledChannels = preferences.stream()
            .collect(Collectors.toMap(
                UserNotificationPreference::getChannelType,
                UserNotificationPreference::getIsEnabled
            ));

        // 2. 활성화된 채널별로 알림 요청 생성
        for (ChannelType channel : event.getChannels()) {
            if (enabledChannels.getOrDefault(channel, true)) {
                createNotificationRequest(event, channel);
            }
        }
    }

    private void createNotificationRequest(NotificationEvent event, ChannelType channel) {
        try {
            String templateCode = generateTemplateCode(event.getEventType(), channel);
            String recipient = getRecipient(event, channel);

            NotificationRequest request = NotificationRequest.builder()
                .eventId(event.getEventId())
                .eventType(event.getEventType())
                .userId(event.getUserId())
                .templateCode(templateCode)
                .channelType(channel)
                .recipient(recipient)
                .variables(event.getData())
                .status(NotificationStatus.PENDING)
                .build();

            notificationRequestRepository.save(request);

            // 비동기로 알림 발송 처리
            channelRouter.routeNotification(request);

        } catch (Exception e) {
            log.error("Failed to create notification request: {} for channel: {}", 
                event.getEventId(), channel, e);
        }
    }

    private String generateTemplateCode(String eventType, ChannelType channel) {
        return eventType + "_" + channel.name();
    }

    private String getRecipient(NotificationEvent event, ChannelType channel) {
        switch (channel) {
            case EMAIL:
                return event.getUserEmail();
            case SMS:
                return event.getUserPhone();
            case PUSH:
                return event.getUserId().toString();
            default:
                throw new IllegalArgumentException("Unsupported channel type: " + channel);
        }
    }
}

6. 템플릿 엔진 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationTemplateEngine {

    private final NotificationTemplateRepository templateRepository;

    public NotificationContent generateContent(String templateCode, Map<String, Object> variables) {
        NotificationTemplate template = templateRepository.findByTemplateCodeAndIsActiveTrue(templateCode)
            .orElseThrow(() -> new NotificationTemplateNotFoundException(templateCode));

        String processedSubject = processTemplate(template.getSubject(), variables);
        String processedContent = processTemplate(template.getContent(), variables);

        return NotificationContent.builder()
            .subject(processedSubject)
            .content(processedContent)
            .channelType(template.getChannelType())
            .build();
    }

    private String processTemplate(String template, Map<String, Object> variables) {
        if (template == null) {
            return null;
        }

        String result = template;
        for (Map.Entry<String, Object> entry : variables.entrySet()) {
            String placeholder = "{{" + entry.getKey() + "}}";
            String value = entry.getValue() != null ? entry.getValue().toString() : "";
            result = result.replace(placeholder, value);
        }

        return result;
    }
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationContent {
    private String subject;
    private String content;
    private ChannelType channelType;
}

7. 채널별 발송 서비스

// 채널 라우터
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationChannelRouter {

    private final Map<ChannelType, NotificationChannelService> channelServices;
    private final NotificationRequestRepository requestRepository;

    @Async
    public void routeNotification(NotificationRequest request) {
        try {
            // 상태를 PROCESSING으로 업데이트
            request.setStatus(NotificationStatus.PROCESSING);
            requestRepository.save(request);

            NotificationChannelService channelService = channelServices.get(request.getChannelType());
            if (channelService == null) {
                throw new UnsupportedChannelException(request.getChannelType());
            }

            channelService.sendNotification(request);

        } catch (Exception e) {
            log.error("Failed to route notification: {}", request.getId(), e);
            handleFailure(request, e);
        }
    }

    private void handleFailure(NotificationRequest request, Exception e) {
        request.setStatus(NotificationStatus.FAILED);
        request.setErrorMessage(e.getMessage());
        request.setRetryCount(request.getRetryCount() + 1);
        requestRepository.save(request);

        // 재시도 로직 (간단한 구현)
        if (request.getRetryCount() < 3) {
            // 지연 후 재시도 (실제로는 더 정교한 재시도 전략 필요)
            CompletableFuture.delayedExecutor(Duration.ofMinutes(5))
                .execute(() -> routeNotification(request));
        }
    }
}

// 이메일 발송 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailNotificationService implements NotificationChannelService {

    private final JavaMailSender mailSender;
    private final NotificationTemplateEngine templateEngine;
    private final NotificationRequestRepository requestRepository;
    private final NotificationHistoryRepository historyRepository;

    @Override
    public void sendNotification(NotificationRequest request) {
        try {
            NotificationContent content = templateEngine.generateContent(
                request.getTemplateCode(), request.getVariables());

            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

            helper.setTo(request.getRecipient());
            helper.setSubject(content.getSubject());
            helper.setText(content.getContent(), true);
            helper.setFrom("noreply@example.com");

            mailSender.send(message);

            // 성공 처리
            request.setStatus(NotificationStatus.SENT);
            request.setSentAt(LocalDateTime.now());
            requestRepository.save(request);

            // 발송 이력 저장
            saveHistory(request, content, NotificationStatus.SENT, null);

            log.info("Email notification sent successfully: {}", request.getId());

        } catch (Exception e) {
            log.error("Failed to send email notification: {}", request.getId(), e);

            // 실패 처리
            request.setStatus(NotificationStatus.FAILED);
            request.setErrorMessage(e.getMessage());
            requestRepository.save(request);

            saveHistory(request, null, NotificationStatus.FAILED, e.getMessage());

            throw new NotificationSendException("Failed to send email", e);
        }
    }

    private void saveHistory(NotificationRequest request, NotificationContent content, 
                           NotificationStatus status, String errorMessage) {
        NotificationHistory history = NotificationHistory.builder()
            .requestId(request.getId())
            .channelType(request.getChannelType())
            .recipient(request.getRecipient())
            .subject(content != null ? content.getSubject() : null)
            .content(content != null ? content.getContent() : null)
            .status(status)
            .provider("SMTP")
            .errorMessage(errorMessage)
            .sentAt(LocalDateTime.now())
            .build();

        historyRepository.save(history);
    }
}

// SMS 발송 서비스 (Twilio 예제)
@Service
@RequiredArgsConstructor
@Slf4j
public class SmsNotificationService implements NotificationChannelService {

    private final NotificationTemplateEngine templateEngine;
    private final NotificationRequestRepository requestRepository;
    private final NotificationHistoryRepository historyRepository;

    @Value("${twilio.account.sid}")
    private String accountSid;

    @Value("${twilio.auth.token}")
    private String authToken;

    @Value("${twilio.phone.number}")
    private String fromPhoneNumber;

    @Override
    public void sendNotification(NotificationRequest request) {
        try {
            Twilio.init(accountSid, authToken);

            NotificationContent content = templateEngine.generateContent(
                request.getTemplateCode(), request.getVariables());

            Message message = Message.creator(
                new PhoneNumber(request.getRecipient()),
                new PhoneNumber(fromPhoneNumber),
                content.getContent())
                .create();

            // 성공 처리
            request.setStatus(NotificationStatus.SENT);
            request.setSentAt(LocalDateTime.now());
            requestRepository.save(request);

            // 발송 이력 저장
            saveHistory(request, content, NotificationStatus.SENT, null, message.getSid());

            log.info("SMS notification sent successfully: {}", request.getId());

        } catch (Exception e) {
            log.error("Failed to send SMS notification: {}", request.getId(), e);

            // 실패 처리
            request.setStatus(NotificationStatus.FAILED);
            request.setErrorMessage(e.getMessage());
            requestRepository.save(request);

            saveHistory(request, null, NotificationStatus.FAILED, e.getMessage(), null);

            throw new NotificationSendException("Failed to send SMS", e);
        }
    }

    private void saveHistory(NotificationRequest request, NotificationContent content,
                           NotificationStatus status, String errorMessage, String externalId) {
        NotificationHistory history = NotificationHistory.builder()
            .requestId(request.getId())
            .channelType(request.getChannelType())
            .recipient(request.getRecipient())
            .content(content != null ? content.getContent() : null)
            .status(status)
            .provider("TWILIO")
            .externalId(externalId)
            .errorMessage(errorMessage)
            .sentAt(LocalDateTime.now())
            .build();

        historyRepository.save(history);
    }
}

8. 실제 사용 예제

// 주문 서비스에서 알림 발송
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final NotificationEventPublisher notificationPublisher;
    private final OrderRepository orderRepository;

    @Transactional
    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        // 주문 상태 업데이트
        order.setStatus(OrderStatus.COMPLETED);
        order.setCompletedAt(LocalDateTime.now());
        orderRepository.save(order);

        // 알림 이벤트 발행
        OrderCompletedEvent event = OrderCompletedEvent.builder()
            .orderId(order.getId().toString())
            .userId(order.getUserId())
            .userEmail(order.getUserEmail())
            .customerName(order.getCustomerName())
            .productName(order.getProductName())
            .totalAmount(order.getTotalAmount())
            .orderDate(order.getOrderDate().toString())
            .deliveryAddress(order.getDeliveryAddress())
            .occurredAt(LocalDateTime.now())
            .build();

        notificationPublisher.publishOrderCompleted(event);

        log.info("Order completed and notification event published: {}", orderId);
    }
}

// 알림 템플릿 등록 예제
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationTemplateInitializer {

    private final NotificationTemplateRepository templateRepository;

    @PostConstruct
    public void initializeTemplates() {
        createOrderCompletedEmailTemplate();
        createOrderCompletedSmsTemplate();
    }

    private void createOrderCompletedEmailTemplate() {
        String emailContent = """
            <html>
            <body>
                <h2>주문이 완료되었습니다!</h2>
                <p>안녕하세요, {{customerName}}님!</p>
                <p>주문해주신 상품의 주문이 성공적으로 완료되었습니다.</p>

                <div style="border: 1px solid #ddd; padding: 20px; margin: 20px 0;">
                    <h3>주문 정보</h3>
                    <p><strong>주문 번호:</strong> {{orderId}}</p>
                    <p><strong>상품명:</strong> {{productName}}</p>
                    <p><strong>결제 금액:</strong> {{totalAmount}}원</p>
                    <p><strong>주문 날짜:</strong> {{orderDate}}</p>
                    <p><strong>배송 주소:</strong> {{deliveryAddress}}</p>
                </div>

                <p>빠른 시일 내에 배송 준비를 완료하겠습니다.</p>
                <p>감사합니다.</p>
            </body>
            </html>
            """;

        NotificationTemplate template = NotificationTemplate.builder()
            .templateCode("ORDER_COMPLETED_EMAIL")
            .templateName("주문 완료 이메일")
            .channelType(ChannelType.EMAIL)
            .subject("주문이 완료되었습니다 - 주문번호: {{orderId}}")
            .content(emailContent)
            .variables(Arrays.asList("customerName", "orderId", "productName", 
                                   "totalAmount", "orderDate", "deliveryAddress"))
            .isActive(true)
            .build();

        templateRepository.save(template);
        log.info("Order completed email template created");
    }

    private void createOrderCompletedSmsTemplate() {
        String smsContent = """
            [쇼핑몰] {{customerName}}님, 주문이 완료되었습니다.
            주문번호: {{orderId}}
            상품: {{productName}}
            금액: {{totalAmount}}원
            배송지: {{deliveryAddress}}
            감사합니다.
            """;

        NotificationTemplate template = NotificationTemplate.builder()
            .templateCode("ORDER_COMPLETED_SMS")
            .templateName("주문 완료 SMS")
            .channelType(ChannelType.SMS)
            .content(smsContent)
            .variables(Arrays.asList("customerName", "orderId", "productName", 
                                   "totalAmount", "deliveryAddress"))
            .isActive(true)
            .build();

        templateRepository.save(template);
        log.info("Order completed SMS template created");
    }
}

알림 서비스의 장점

1. 확장성과 유연성

  • 독립적 확장: 알림 서비스는 다른 서비스와 독립적으로 스케일링 가능
  • 채널 추가 용이: 새로운 알림 채널(카카오톡, 슬랙 등) 추가 시 기존 코드 변경 최소화
  • 템플릿 관리: 운영자가 직접 알림 내용을 수정할 수 있는 템플릿 시스템

2. 안정성과 복원력

  • 비동기 처리: 메시지 큐를 통한 비동기 처리로 메인 서비스 성능에 영향 없음
  • 재시도 메커니즘: 실패한 알림에 대한 자동 재시도 기능
  • 장애 격리: 알림 서비스 장애가 다른 서비스에 영향을 주지 않음

3. 운영 효율성

  • 중앙 집중 관리: 모든 알림을 한 곳에서 관리하고 모니터링
  • 발송 이력 추적: 완전한 알림 발송 이력과 상태 추적 가능
  • 사용자 설정: 사용자별 알림 수신 설정 관리

알림 서비스 구현 시 고려사항

1. 성능 최적화

// 배치 처리를 통한 성능 개선
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationBatchProcessor {

    private final NotificationRequestRepository requestRepository;
    private final NotificationChannelRouter channelRouter;

    @Scheduled(fixedDelay = 5000) // 5초마다 실행
    public void processPendingNotifications() {
        List<NotificationRequest> pendingRequests = 
            requestRepository.findByStatusOrderByCreatedAtAsc(
                NotificationStatus.PENDING, PageRequest.of(0, 100));

        if (!pendingRequests.isEmpty()) {
            log.info("Processing {} pending notifications", pendingRequests.size());

            // 채널별로 그룹화하여 병렬 처리
            Map<ChannelType, List<NotificationRequest>> groupedRequests = 
                pendingRequests.stream()
                    .collect(Collectors.groupingBy(NotificationRequest::getChannelType));

            groupedRequests.forEach((channelType, requests) -> {
                CompletableFuture.runAsync(() -> {
                    requests.forEach(channelRouter::routeNotification);
                });
            });
        }
    }
}

2. 모니터링 및 알림

// 알림 서비스 모니터링
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationMonitoringService {

    private final NotificationRequestRepository requestRepository;
    private final MeterRegistry meterRegistry;

    @EventListener
    public void handleNotificationSent(NotificationSentEvent event) {
        // 메트릭 수집
        Counter.builder("notification.sent")
            .tag("channel", event.getChannelType().name())
            .tag("event_type", event.getEventType())
            .register(meterRegistry)
            .increment();
    }

    @EventListener
    public void handleNotificationFailed(NotificationFailedEvent event) {
        // 실패 메트릭 수집
        Counter.builder("notification.failed")
            .tag("channel", event.getChannelType().name())
            .tag("event_type", event.getEventType())
            .tag("error_type", event.getErrorType())
            .register(meterRegistry)
            .increment();
    }

    @Scheduled(fixedRate = 60000) // 1분마다 체크
    public void checkNotificationHealth() {
        long failedCount = requestRepository.countByStatusAndCreatedAtAfter(
            NotificationStatus.FAILED, 
            LocalDateTime.now().minusMinutes(5)
        );

        if (failedCount > 10) {
            log.warn("High notification failure rate detected: {} failures in last 5 minutes", 
                failedCount);
            // 알림 또는 대시보드에 경고 전송
        }
    }
}

3. 보안 고려사항

// 개인정보 보호를 위한 데이터 마스킹
@Service
@RequiredArgsConstructor
public class NotificationSecurityService {

    public String maskPersonalInfo(String data, String type) {
        if (data == null) return null;

        switch (type) {
            case "email":
                return maskEmail(data);
            case "phone":
                return maskPhone(data);
            default:
                return data;
        }
    }

    private String maskEmail(String email) {
        if (email == null || !email.contains("@")) return email;

        String[] parts = email.split("@");
        String localPart = parts[0];
        String domain = parts[1];

        if (localPart.length() <= 2) return email;

        return localPart.charAt(0) + "***" + localPart.charAt(localPart.length() - 1) + "@" + domain;
    }

    private String maskPhone(String phone) {
        if (phone == null || phone.length() < 8) return phone;

        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
    }
}

실제 운영 시나리오

1. 대용량 알림 처리

// 대용량 알림 처리를 위한 샤딩 전략
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationShardingService {

    private final List<NotificationChannelService> emailServices;
    private final List<NotificationChannelService> smsServices;

    public void sendBulkNotifications(List<NotificationRequest> requests) {
        // 사용자 ID 기반 샤딩
        Map<Integer, List<NotificationRequest>> shardedRequests = 
            requests.stream()
                .collect(Collectors.groupingBy(
                    req -> Math.abs(req.getUserId().hashCode() % 4)));

        shardedRequests.forEach((shardId, shardRequests) -> {
            CompletableFuture.runAsync(() -> {
                processShardRequests(shardId, shardRequests);
            });
        });
    }

    private void processShardRequests(int shardId, List<NotificationRequest> requests) {
        log.info("Processing {} requests in shard {}", requests.size(), shardId);

        for (NotificationRequest request : requests) {
            try {
                NotificationChannelService service = getServiceForShard(shardId, request.getChannelType());
                service.sendNotification(request);
            } catch (Exception e) {
                log.error("Failed to send notification in shard {}: {}", shardId, request.getId(), e);
            }
        }
    }

    private NotificationChannelService getServiceForShard(int shardId, ChannelType channelType) {
        switch (channelType) {
            case EMAIL:
                return emailServices.get(shardId % emailServices.size());
            case SMS:
                return smsServices.get(shardId % smsServices.size());
            default:
                throw new IllegalArgumentException("Unsupported channel type: " + channelType);
        }
    }
}

2. A/B 테스트 지원

// A/B 테스트를 위한 알림 템플릿 관리
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationABTestService {

    private final NotificationTemplateRepository templateRepository;
    private final Random random = new Random();

    public String selectTemplate(String baseTemplateCode, Long userId) {
        // 사용자 ID 기반으로 A/B 테스트 그룹 결정
        boolean isTestGroup = (userId % 100) < 50; // 50% 사용자를 테스트 그룹으로

        if (isTestGroup) {
            String testTemplateCode = baseTemplateCode + "_TEST";
            boolean testTemplateExists = templateRepository.existsByTemplateCodeAndIsActiveTrue(testTemplateCode);

            if (testTemplateExists) {
                log.debug("Using test template for user {}: {}", userId, testTemplateCode);
                return testTemplateCode;
            }
        }

        log.debug("Using default template for user {}: {}", userId, baseTemplateCode);
        return baseTemplateCode;
    }
}

알림 서비스 vs 기존 방식 비교

항목 기존 방식 (각 서비스에서 직접 발송) 알림 서비스 방식
결합도 높음 (각 서비스가 알림 로직 포함) 낮음 (이벤트 기반 분리)
코드 중복 많음 (각 서비스마다 알림 코드) 없음 (중앙 집중화)
확장성 제한적 (각 서비스 개별 확장) 높음 (독립적 확장)
알림 통합 관리 어려움 쉬움
장애 격리 낮음 (알림 실패 시 메인 로직 영향) 높음 (완전 분리)
성능 동기 처리로 인한 지연 비동기 처리로 빠른 응답
모니터링 분산되어 어려움 중앙 집중 모니터링
템플릿 관리 코드 변경 필요 운영자 직접 수정 가능

결론

MSA 환경에서 알림 서비스는 시스템의 확장성, 안정성, 운영 효율성을 크게 향상시키는 핵심 컴포넌트입니다.

알림 서비스의 핵심 가치는 다음과 같습니다:

확장성: 각 서비스는 비즈니스 로직에만 집중하고, 알림 서비스는 독립적으로 확장하여 대용량 알림 처리가 가능합니다.

안정성: 메시지 큐를 통한 비동기 처리와 재시도 메커니즘으로 높은 안정성을 제공하며, 알림 실패가 메인 서비스에 영향을 주지 않습니다.

운영 효율성: 중앙 집중화된 템플릿 관리와 발송 이력 추적으로 운영 효율성을 대폭 향상시킵니다.

다만 메시지 큐 관리의 복잡성, 템플릿 관리 오버헤드, 초기 구현 복잡성 등을 고려해야 합니다.

실제 도입 시에는 서비스의 규모, 알림 발송량, 개발팀의 역량을 종합적으로 고려하여 단계적으로 적용하는 것이 중요합니다. 초기에는 핵심 알림부터 시작하여 점진적으로 확장하는 전략을 추천합니다.

728x90
728x90

데이터베이스가 성장하면서 단일 테이블로는 더 이상 효율적인 데이터 관리가 어려워지는 시점이 옵니다. 특히 대용량 데이터를 다룰 때 성능 병목이 발생하게 되는데, 이를 해결하기 위한 대표적인 방법이 파티셔닝(Partitioning)샤딩(Sharding)입니다.

이번 글에서는 Spring Boot와 MySQL을 사용하여 Product 테이블을 예제로 두 방법의 차이점과 장단점을 살펴보겠습니다.

예제 시나리오

우리가 다룰 Product는 다음과 같은 구조를 가집니다:

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String type; // "상의", "하의", "모자", "신발"
    private BigDecimal price;
    private Integer stock;
    private LocalDateTime createdAt;

    // 생성자, getter, setter 생략
}

파티셔닝 (Partitioning)

파티셔닝이란?

파티셔닝은 하나의 데이터베이스 내에서 테이블을 논리적으로 분할하는 기법입니다. 물리적으로는 여전히 같은 데이터베이스에 존재하지만, 데이터를 여러 파티션으로 나누어 관리합니다.

파티셔닝 구조도

┌─────────────────────────────────────────┐
│              MySQL Database             │
├─────────────────────────────────────────┤
│  Product Table (Partitioned by type)    │
├─────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐      │
│  │ Partition 1 │  │ Partition 2 │      │
│  │   (상의)     │  │   (하의)     │      │
│  └─────────────┘  └─────────────┘      │
│  ┌─────────────┐  ┌─────────────┐      │
│  │ Partition 3 │  │ Partition 4 │      │
│  │   (모자)     │  │   (신발)     │      │
│  └─────────────┘  └─────────────┘      │
└─────────────────────────────────────────┘

MySQL 파티셔닝 구현

-- 파티션 테이블 생성
CREATE TABLE product (
    id BIGINT AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    type VARCHAR(50) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, type)
) 
PARTITION BY LIST COLUMNS(type) (
    PARTITION p_top VALUES IN ('상의'),
    PARTITION p_bottom VALUES IN ('하의'),
    PARTITION p_hat VALUES IN ('모자'),
    PARTITION p_shoes VALUES IN ('신발')
);

Spring Boot에서 파티셔닝 활용

@Repository
public class ProductRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 특정 타입의 상품 조회 (파티션 프루닝 활용)
    public List<Product> findByType(String type) {
        String sql = "SELECT * FROM product WHERE type = ?";
        return jdbcTemplate.query(sql, new Object[]{type}, 
            (rs, rowNum) -> mapRowToProduct(rs));
    }

    // 전체 상품 조회 (모든 파티션 스캔)
    public List<Product> findAll() {
        String sql = "SELECT * FROM product";
        return jdbcTemplate.query(sql, (rs, rowNum) -> mapRowToProduct(rs));
    }

    private Product mapRowToProduct(ResultSet rs) throws SQLException {
        Product product = new Product();
        product.setId(rs.getLong("id"));
        product.setName(rs.getString("name"));
        product.setType(rs.getString("type"));
        product.setPrice(rs.getBigDecimal("price"));
        product.setStock(rs.getInt("stock"));
        product.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
        return product;
    }
}

파티셔닝의 장점

  1. 쿼리 성능 향상: 파티션 프루닝을 통해 필요한 파티션만 스캔
  2. 인덱스 효율성: 각 파티션마다 별도의 인덱스 관리
  3. 유지보수 용이성: 파티션별 백업, 복구 가능

파티셔닝의 한계

  1. 메모리 제약: 여전히 단일 데이터베이스의 메모리 한계
  2. QPS 제한: 하나의 DB 서버가 처리할 수 있는 요청 수의 한계
  3. 스케일링 제약: 수직 확장(Scale-up)에만 의존

샤딩 (Sharding)

샤딩이란?

샤딩은 동일한 스키마를 가진 데이터를 여러 데이터베이스에 수평적으로 분산하는 기법입니다. 각 샤드는 독립적인 데이터베이스 서버에서 실행됩니다.

샤딩 구조도

┌─────────────────┐  ┌─────────────────┐
│   MySQL DB 1    │  │   MySQL DB 2    │
│   (상의 샤드)     │  │   (하의 샤드)      │
├─────────────────┤  ├─────────────────┤
│ Product Table   │  │ Product Table   │
│ - 상의 데이터      │  │ - 하의 데이터      │
└─────────────────┘  └─────────────────┘

┌─────────────────┐  ┌─────────────────┐
│   MySQL DB 3    │  │   MySQL DB 4    │
│   (모자 샤드)     │  │   (신발 샤드)     │
├─────────────────┤  ├─────────────────┤
│ Product Table   │  │ Product Table   │
│ - 모자 데이터      │  │ - 신발 데이터     │
└─────────────────┘  └─────────────────┘

Spring Boot에서 샤딩 구현

1. 다중 데이터소스 설정

@Configuration
public class ShardingConfig {

    @Bean
    @Primary
    public DataSource topDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://localhost:3306/product_top")
            .username("user")
            .password("password")
            .build();
    }

    @Bean
    public DataSource bottomDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://localhost:3307/product_bottom")
            .username("user")
            .password("password")
            .build();
    }

    @Bean
    public DataSource hatDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://localhost:3308/product_hat")
            .username("user")
            .password("password")
            .build();
    }

    @Bean
    public DataSource shoesDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://localhost:3309/product_shoes")
            .username("user")
            .password("password")
            .build();
    }
}

2. 샤드 라우팅 서비스

@Service
public class ShardingService {

    private final Map<String, JdbcTemplate> shardMap;

    public ShardingService(
            @Qualifier("topDataSource") DataSource topDs,
            @Qualifier("bottomDataSource") DataSource bottomDs,
            @Qualifier("hatDataSource") DataSource hatDs,
            @Qualifier("shoesDataSource") DataSource shoesDs) {

        this.shardMap = Map.of(
            "상의", new JdbcTemplate(topDs),
            "하의", new JdbcTemplate(bottomDs),
            "모자", new JdbcTemplate(hatDs),
            "신발", new JdbcTemplate(shoesDs)
        );
    }

    public JdbcTemplate getShardByType(String type) {
        JdbcTemplate shard = shardMap.get(type);
        if (shard == null) {
            throw new IllegalArgumentException("Unknown product type: " + type);
        }
        return shard;
    }

    public Collection<JdbcTemplate> getAllShards() {
        return shardMap.values();
    }
}

3. 샤딩 Repository 구현

@Repository
public class ShardedProductRepository {

    @Autowired
    private ShardingService shardingService;

    // 특정 타입의 상품 조회 (단일 샤드 접근)
    public List<Product> findByType(String type) {
        JdbcTemplate shard = shardingService.getShardByType(type);
        String sql = "SELECT * FROM product WHERE type = ?";
        return shard.query(sql, new Object[]{type}, this::mapRowToProduct);
    }

    // 상품 저장 (적절한 샤드로 라우팅)
    public void save(Product product) {
        JdbcTemplate shard = shardingService.getShardByType(product.getType());
        String sql = "INSERT INTO product (name, type, price, stock) VALUES (?, ?, ?, ?)";
        shard.update(sql, product.getName(), product.getType(), 
                    product.getPrice(), product.getStock());
    }

    // 전체 상품 조회 (모든 샤드 접근 - 성능 주의!)
    public List<Product> findAll() {
        List<Product> allProducts = new ArrayList<>();

        // 병렬 처리로 성능 개선
        List<CompletableFuture<List<Product>>> futures = 
            shardingService.getAllShards().stream()
                .map(shard -> CompletableFuture.supplyAsync(() -> 
                    shard.query("SELECT * FROM product", this::mapRowToProduct)))
                .collect(Collectors.toList());

        futures.forEach(future -> {
            try {
                allProducts.addAll(future.get());
            } catch (Exception e) {
                throw new RuntimeException("Failed to fetch from shard", e);
            }
        });

        return allProducts;
    }

    // 범위 검색 (가격 기준 - 모든 샤드 접근 필요)
    public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
        List<Product> results = new ArrayList<>();
        String sql = "SELECT * FROM product WHERE price BETWEEN ? AND ?";

        for (JdbcTemplate shard : shardingService.getAllShards()) {
            List<Product> shardResults = shard.query(sql, 
                new Object[]{minPrice, maxPrice}, this::mapRowToProduct);
            results.addAll(shardResults);
        }

        return results.stream()
                .sorted(Comparator.comparing(Product::getPrice))
                .collect(Collectors.toList());
    }

    private Product mapRowToProduct(ResultSet rs, int rowNum) throws SQLException {
        Product product = new Product();
        product.setId(rs.getLong("id"));
        product.setName(rs.getString("name"));
        product.setType(rs.getString("type"));
        product.setPrice(rs.getBigDecimal("price"));
        product.setStock(rs.getInt("stock"));
        product.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
        return product;
    }
}

샤딩의 장점

  1. 수평 확장성: 새로운 서버 추가로 용량과 성능 확장 가능
  2. 메모리 분산: 각 샤드가 독립적인 메모리 공간 사용
  3. QPS 향상: 여러 서버가 동시에 요청 처리
  4. 장애 격리: 한 샤드의 장애가 다른 샤드에 영향 없음

샤딩의 어려움

1. 범위 검색의 복잡성

// 문제점: 모든 샤드를 조회해야 하므로 성능 저하
public List<Product> findExpensiveProducts(BigDecimal threshold) {
    List<Product> results = new ArrayList<>();

    // 모든 샤드에서 데이터 수집
    for (JdbcTemplate shard : shardingService.getAllShards()) {
        String sql = "SELECT * FROM product WHERE price > ?";
        results.addAll(shard.query(sql, new Object[]{threshold}, 
                      this::mapRowToProduct));
    }

    return results.stream()
            .sorted(Comparator.comparing(Product::getPrice).reversed())
            .collect(Collectors.toList());
}

2. 트랜잭션 관리의 복잡성

// 문제점: 여러 샤드에 걸친 트랜잭션 처리
@Transactional
public void transferStock(Long fromProductId, String fromType, 
                         Long toProductId, String toType, Integer quantity) {

    if (!fromType.equals(toType)) {
        // 서로 다른 샤드 간의 트랜잭션 - 복잡한 2PC 필요
        throw new UnsupportedOperationException(
            "Cross-shard transactions not supported");
    }

    JdbcTemplate shard = shardingService.getShardByType(fromType);
    // 같은 샤드 내에서만 트랜잭션 보장 가능
    shard.update("UPDATE product SET stock = stock - ? WHERE id = ?", 
                quantity, fromProductId);
    shard.update("UPDATE product SET stock = stock + ? WHERE id = ?", 
                quantity, toProductId);
}

3. 조인 쿼리의 제약

// 문제점: 서로 다른 샤드의 데이터를 조인할 수 없음
public class OrderService {

    // 주문과 상품이 다른 샤드에 있을 경우 애플리케이션 레벨에서 조인
    public OrderDetailDTO getOrderWithProducts(Long orderId) {
        Order order = orderRepository.findById(orderId);

        List<Product> products = new ArrayList<>();
        for (OrderItem item : order.getItems()) {
            // 각 상품을 개별적으로 조회 (N+1 문제 발생 가능)
            Product product = shardedProductRepository
                .findByType(item.getProductType())
                .stream()
                .filter(p -> p.getId().equals(item.getProductId()))
                .findFirst()
                .orElse(null);
            if (product != null) {
                products.add(product);
            }
        }

        return new OrderDetailDTO(order, products);
    }
}

파티셔닝 vs 샤딩 비교

항목 파티셔닝 샤딩
확장성 수직 확장만 가능 수평 확장 가능
메모리 단일 DB 메모리 한계 각 샤드별 독립적 메모리
QPS 단일 DB 서버 한계 여러 서버로 분산 처리
트랜잭션 ACID 보장 크로스 샤드 트랜잭션 복잡
조인 일반적인 조인 가능 크로스 샤드 조인 불가
범위 검색 효율적 모든 샤드 접근 필요
복잡성 상대적으로 단순 애플리케이션 레벨 복잡성 증가
장애 격리 전체 DB 영향 샤드별 독립적

실제 사용 시나리오

파티셔닝이 적합한 경우

@Service
public class AnalyticsService {

    // 특정 타입의 상품 분석 - 파티션 프루닝 활용
    public ProductAnalytics analyzeByType(String type) {
        List<Product> products = productRepository.findByType(type);

        return ProductAnalytics.builder()
            .totalCount(products.size())
            .averagePrice(calculateAveragePrice(products))
            .topSellingProducts(findTopSelling(products))
            .build();
    }
}

샤딩이 적합한 경우

@Service
public class ProductService {

    // 대용량 데이터 처리 - 각 샤드에서 병렬 처리
    public void updatePricesForType(String type, BigDecimal multiplier) {
        JdbcTemplate shard = shardingService.getShardByType(type);

        String sql = "UPDATE product SET price = price * ? WHERE type = ?";
        int updatedRows = shard.update(sql, multiplier, type);

        log.info("Updated {} products in {} shard", updatedRows, type);
    }

    // 높은 QPS 처리 - 각 샤드가 독립적으로 처리
    @Async
    public CompletableFuture<List<Product>> findTopProductsByType(String type) {
        JdbcTemplate shard = shardingService.getShardByType(type);

        String sql = "SELECT * FROM product WHERE type = ? ORDER BY price DESC LIMIT 10";
        List<Product> products = shard.query(sql, new Object[]{type}, 
                                           this::mapRowToProduct);

        return CompletableFuture.completedFuture(products);
    }
}

결론

파티셔닝과 샤딩은 각각 다른 상황에서 유용한 데이터베이스 확장 기법입니다.

파티셔닝은 비교적 단순한 구현으로 쿼리 성능을 향상시킬 수 있지만, 근본적인 확장성 문제는 해결하지 못합니다. 중간 규모의 데이터에서 성능 개선이 필요한 경우 적합합니다.

샤딩은 더 복잡한 구현이 필요하지만, 진정한 수평 확장을 통해 대용량 데이터와 높은 QPS를 처리할 수 있습니다. 다만 크로스 샤드 쿼리, 트랜잭션 관리 등의 복잡성을 감수해야 합니다.

실제 서비스에서는 데이터의 성격, 트래픽 패턴, 확장 계획을 종합적으로 고려하여 적절한 전략을 선택하는 것이 중요합니다. 때로는 두 방법을 조합하여 사용하는 것도 좋은 선택이 될 수 있습니다.

728x90
728x90

개발을 하다 보면 가끔 "모노레포"라는 단어를 들어본 적이 있을 것이다.

프로젝트를 관리할 때 모노레포로 구성하면 관리하기 편하다고 하지만, 경험상 모노레포 구조를 가진 프로젝트들은 결국 확장됨에 따라 분리하는 절차를 밟게 되었었다.

오늘은 모노레포가 탄생한 배경, 그리고 어떨 때 적용하라고 만든 구조인지 알아보도록 하겠다.


Monorepo와 Polyrepo

Monorepo는 말 그대로 Mono(하나의) + Repo(Repository), 즉 여러 애플리케이션이나 라이브러리를 하나의 저장소에서 관리하는 구조이다.

그럼 지금까지 사용해 왔던 Polyrepo의 어떤 점을 보완하고자 나온 구조일까?

우선 Polyrepo의 장단점에 대해 알아보자.

Polyrepo는 다중 저장소를 이용하는 구조로, 프로젝트마다 별도의 저장소를 이용하는 구조이다.


Polyrepo의 장점

  • 각 프로젝트별 독립적인 모듈화가 가능하다.
  • 배포에 유연하다.
  • 서비스 수가 많아질수록 관리가 용이하다.

즉, 프로젝트들이 물리적으로 분리되어 있어 연관되지 않는 Merge 충돌과 같은 상황을 방지할 수 있는 등 물리적인 분리에서 오는 장점들이 존재한다.


Polyrepo의 단점

반대로 이러한 특징은 단점으로 적용되기도 한다.

  • 공통 라이브러리의 버전 반영이 번거롭다.
  • 프로젝트 간 동기화 및 호환성 유지가 어렵다.
  • 코드 리뷰 및 CI/CD 구성이 분산되어 있어 설정 비용이 증가한다.

이런 단점들을 개선하고자 등장한 구조가 바로 Monorepo다.


Monorepo의 장점

Monorepo는 여러 프로젝트를 하나의 저장소에서 관리한다.
이는 특히 다음과 같은 상황에서 큰 장점을 가진다.

  1. 공통 코드의 관리가 용이하다
    • 여러 서비스가 공통으로 사용하는 utilsdomain 모듈을 하나의 저장소에서 직접 수정 및 반영 가능
  2. 원자적 변경(Atomic Change)이 가능하다
    • 여러 프로젝트에 걸친 인터페이스 변경도 하나의 커밋으로 처리 가능
  3. 일관된 CI/CD 구성
    • 통합적인 CI 파이프라인 구성 가능, 디렉터리 기반 변경 감지 등 최적화 가능
  4. 코드 가시성과 협업에 유리하다
    • 전체 구조를 한눈에 파악할 수 있어 신규 입사자나 타 팀원 간 협업 시 유리

Monorepo의 단점

물론 Monorepo도 단점이 있다.
가장 대표적인 문제는 스케일에 따른 복잡성 증가다.

  1. 빌드 시간 증가
    • 빌드 시간이 오래 걸리며, 이를 해결하려면 Bazel, Nx, Turborepo 등의 도구 필요
  2. 접근 제어의 어려움
    • 디렉터리 단위 접근 권한 설정이 어렵다
  3. Git 성능 저하
    • 파일 수가 많아질수록 Git 자체가 느려질 수 있음
  4. 심리적 복잡성
    • 전체 코드량이 많아지며 구조 파악에 부담을 줄 수 있음

Monorepo를 적용하기 좋은 상황

다음과 같은 경우에 Monorepo 구조는 적합하다.

  • 여러 서비스가 같은 주기로 배포되고, 공통된 라이브러리를 사용하는 경우
  • 초기 스타트업 환경처럼 팀 규모가 작고 변경이 잦은 경우
  • 기능 단위의 동시 변경이 자주 필요한 경우
  • 전체 시스템을 하나의 흐름에서 통합 관리하고 싶은 경우

반면, 대규모 서비스나 팀에서는 신중한 검토가 필요하다.
Google도 Monorepo를 사용하고 있지만, 이를 위해 독자적인 빌드 시스템과 버전 관리 시스템을 따로 구축한 사례다.


MultiModule 구조와의 비교

MultiModule은 주로 하나의 프로젝트 안에서 여러 기능을 모듈화해 관리하는 방식이다.
Java 기반의 프로젝트(Spring 등)에서 자주 볼 수 있다.
이 구조는 물리적으로는 Monorepo, 논리적으로는 Polyrepo와 유사한 형태를 갖는다.

MultiModule의 특징

  • 하나의 Git 저장소 안에 여러 모듈 존재
  • core, common, domain 같은 공통 모듈을 다른 모듈들이 참조
  • 하나의 빌드 스크립트(Gradle 등)로 통합 관리

Monorepo vs MultiModule

항목 Monorepo MultiModule
저장소 구조 여러 프로젝트를 하나의 저장소에서 관리 하나의 프로젝트 안에서 모듈로 분리
독립성 높은 유연성 (서비스 단위 프로젝트 존재 가능) 하나의 애플리케이션 중심 (빌드 및 배포 단위가 동일)
공통 코드 관리 공통 라이브러리를 독립 패키지로도 구성 가능 대부분 동일한 버전을 공유
빌드 관리 고급 도구 필요 (Bazel, Nx 등) Gradle, Maven 기반 관리

즉, MultiModule은 단일 서비스 또는 애플리케이션의 모듈화를 위한 설계,
Monorepo는 복수의 애플리케이션 또는 서비스 단위까지 고려한 구조라는 차이가 있다.


마무리

정리하자면, Monorepo는 통합성과 효율성에서 큰 장점을 가지지만, 이를 유지하려면 명확한 설계 기준과 도구의 도움이 필요하다.
처음에는 편리하지만, 규모가 커지면 관리 비용도 함께 커진다는 점을 명심해야 한다.

 

Monorepo의 경우 Bazel과 같은 고급 도구가 필요한 만큼 팀과 프로젝트의 성격에 따라 Polyrepo, Monorepo, MultiModule 중 어떤 구조를 적용할지 신중하게 고민하여 적용하는 것이 필요하다 본다.

728x90
728x90

오늘은 필자가 경험한 인증 서비스 Refactoring 다뤄보도록 하겠다.

현업에서 다룬 만큼 어떻게 그리고 왜 Refactoring 했는지와 연관되지 않는 부분은 제거하고 작성하겠다.

 

앞서 해당 글에서 작성하면서 사용한 도메인의 설정을 정의하고 가겠다.

  • 상품: 유저가 구매하는 상품이며 보통 1년에 1~4개를 구매한다.
             상품 유형은 A, B, C, D 상품이 존재한다.
  • 인증: 유저가 상품을 구매한 구매자인지 인증하기 위한 도메인이며, 현재 인증기간일 경우 할인이 적용된다.

 

우선 필자가 만난 인증 서비스는 아래와 같았다.

Before

간단하게 유저가 상품을 구매하면 인증서버에 해당 유저가 상품을 구매했다고 상품에 명시되어 있는 인증기간 동안 구매자 인증을 해주는 시스템이다.

 

 해당 시스템은 매우 간단하지만 도메인전문가의 니즈는 조금 복잡했다.

  • 고객의 인증기간은 관리자가 조절할 수 있다.
  • 상품을 구매하지 않더라도 관리자가 임의로 인증기간을 할당할 수 있다.
  • 구매자가 현재 얼마만큼의 인증기간을 가지고 있는지 확인해야 한다.
  • 상품마다 인증기간이 다르며, 인증기간 사이에 비인증 기간이 존재할 수 있다.

또한 해당 서버에는 고질적인 문제가 있었다.

왜 어째서인지 고객이 상품을 구매하였지만 인증기간이 존재하지 않는 데이터들도 존재하는 것이었다.

 

왜 그럴까? 

우선 인증서버에서 인증 데이터를 어떤 것들을 저장하는지 확인해 보자.

A시작일 A종료일 B상품 B시작일 B종료일 C상품 C시작일 C종료일 D상품 D시작일 D종료일

 

위 칼럼을 확인 후 필자는 이와 같이 생각했다.

  • 인증은 A, B, C, D상품 인증일 중 하나라도 현재 인증 기간 범위 내에 존재한다면 구매자로 인증된다.
  • 구매한 상품의 타입이 같다면 인증기간을 기존 인증기간이 존재한다면 두 데이터를 비교하여 더 빠른 인증시작일과 더 늦게 종료되는 인증 종료일을 저장한다.
  • 스케줄링을 통해 인증 종료된 인증 데이터에 대해서는 빈칸으로 수정해 준다.( Mysql을 사용하였고, 해당 칼럼 타입 VARCHAR이었다. )

그리고 데이터에도 문제가 있다는 것을 확인하였다.

  • 상품을 구매하였지만 인증데이터가 생성되지 않는 유저가 존재한다.
  • 어째서인지 들어갈 수 없는 날짜가 인증 종료기간에 들어가 있다.
  • 인증기간은 존재하지만 상품 아이디가 없다.

그럼 상황을 정리해 보자.

  • 상품을 구매할 때마다 인증 데이터의 신뢰도가 떨어진다.
  • 현재 인증일인지 확인하기 복잡하다.
  • 테스트한 데이터가 운영 데이터베이스에 있다.
  • 인증 데이터에 상품 아이디가 존재하지 않는 이유는 관리자가 임의로 생성한 데이터였기 때문이다.
  • 이상한 날짜가 들어간 이유는 인증 종료일을 강제로 31일로 넣었기 때문이다.

필자는 위 상황을 정리하며 해당 시스템에 어떤 고객 니즈가 존재하는지, 기존 시스템을 파악하며 어떤 문제가 존재하는지를 파악하였다.

 

이제 이를 해결하기 위해 어떤 고민이 있었고, 어떻게 해결하였는지 알아보자.


Chapter 1. 상품을 구매할 때마다 인증 데이터의 신뢰도가 떨어진다.

필자가 맨 처음 고민한 부분이다.

 

위 인증 데이터는 상품을 구매할 경우 인증기간을 상품과 상관없이 인증기간을 인증 데이터에 저장하고 있었다.

이렇게 하였을 경우 문제점은 이와 같았다.

  • 정상적으로 인증 데이터가 생성되지 않을 수 있다.
  • 상품의 실제 인증기간과 별개의 인증기간이 만들어질 수 있다.
  • 현재 인증기간이 어떤 상품을 구매해서 생긴 것인지 정확히 알기 어렵다.

위와 같은 문제가 발생한다면 이는 또다시 이런 일이 발생하지 않도록 하는 게 좋다 생각했다.

 

그럼 어떻게 풀어낼 수 있을까?

 

우선 간단하게 생각했다.

 

상품을 구매할 때마다 인증기간을 생성한다면 인증기간을 신뢰도가 높고, 효율적으로 관리할 수 있을 것이라 생각했다.

그리하여 인증과 인증기간을 분리하기로 하였다.

인증 테이블

인증 아이디 고객 아이디

인증 기간 테이블

인증 기간 아이디 인증 아이디 상품 아이디 인증 시작일 인증 종료일

 

위와 같이 정규화를 하여 관리한다면 구매한 상품에 대한 인증기간을 정확하게 관리할 수 있다.

 

또한 상품 타입이 늘어나거나 줄어들 때마다 테이블 칼럼을 수정하지 않아도 된다.


Chapter 2. 현재 인증된 고객인지 빠르고 정확하게 확인하고 싶다.

기존 인증 데이터의 경우 A, B, C, D 상품 인증 시작일과 종료일을 하나의 Record로 관리하고 있었다.

 

따라서 sql을 작성한다고 가정하면 이렇다.

SELECT *
FROM 인증
WHERE 유저아이디 = :유저아이디
  AND (
    (A시작일 <= :현재날짜 AND A종료일 >= :현재날짜) OR
    (B시작일 <= :현재날짜 AND B종료일 >= :현재날짜) OR
    (C시작일 <= :현재날짜 AND C종료일 >= :현재날짜) OR
    (D시작일 <= :현재날짜 AND D종료일 >= :현재날짜)
  );

 

하지만 Chapter 1에서 정규화된 테이블을 사용한다면 이와 같이 작성될 것이다.

SELECT 인증아이디
FROM 인증
WHERE 유저아이디 = :유저아이디;


SELECT *
FROM 인증기간
WHERE 인증아이디 = :인증아이디
 AND 시작일 <= :현재날짜
 AND 종료일 >= :현재날짜;

 

 

비정규화 쿼리와 정규화 쿼리를 비교해 보자.

  비정규화 정규화
인덱스 활용도 낮음 높음
유지보수 어려움 (A~D 컬럼 늘어나면 쿼리 수정 필요) 쉬움
쿼리 단순성 조건문 복잡 명확하고 간결
확장성 나쁨 (인증 수 늘면 테이블 변경 필요) 좋음 (행으로 관리)
성능 느림 (OR 조건 병렬평가) 빠름 (범위 인덱스 활용 가능)

 

이와 같이 정규화를 적절하게 적용한다면 매우 많은 이점을 가질 수 있게 된다.

 

물론 서버에서 조회한 다음 로직으로 현재 인증일인지 판단한다 하면 성능자체는 비정규화 방식이 좋을 수도 있다 생각한다.

왜냐면 정규화를 한다면 최소 4배 이상의 데이터가 생길 것이기 때문이다.

 

하지만 유지보수성과 관리의 측면에서 바라본다면 좀 더 효율적으로 관리할 수 있을 것이다.


Chapter 3. 서버가 다운되더라도 인증 기간은 정상적으로 생성하고 싶다.

정규화를 통해 데이터를 잘 관리할 수 있게 되었다면 우리는 다음 문제를 해결해야 한다.

상품을 구매하였는데 인증 기간이 생기지 않는 상황이다.

 

이는 몇 가지 고민을 해볼 수 있다.

1. 하나의 트랜잭션으로 묶는다.

만약 MA라면 가장 간단할 거다.

 

하지만 여기서도 고민해 볼 것은 존재한다 생각한다.

인증 기간이 생성되지 않는다고 상품 구매를 원복 시킨다?

 

뭔가 배보다 배꼽이 커진 거 같다.

 

???: "상품을 구매하였기 때문에 인증 기간이 생성되어야 합니다."

???: "인증 기간이 생성되지 않았기 때문에 상품 구매는 취소됩니다."

 

논리적으로 앞뒤가 바뀐 거 같지 않은가?

 

필자는 이에 주도권은 상품에게 존재한다 생각하였다.

그렇기 때문에 하나의 트랜잭션으로 묶는 것은 적절하지 않다 생각한다.

2. 스케줄링을 돌려 인증 기간을 생성한다.

이 또한 간단하게 처리할 수 있는 방법이라 생각한다.

 

다만 구매한 상품들 중 인증 안된 상품을 어떻게 찾을 것인가?

만약 한다면 n초 전 구매한 상품들은 전부 인증기간을 생성해야 하는 가?

 

이러한 부분을 원활하게 처리하기 위해서는 스케줄링은 적합하지 않다 판단했다.

3. Event를 사용하여 비동기로 생성한다.

상품을 구매하면 상품정보와 고객 정보로 Message를 만들어 발행하는 것이다.

 

이렇게 진행한다면 인증 서비스에서는 해당 Topic으로 발행된 Message를 컨슈밍 하여 인증 기간을 생성하는 것으로 깔끔한 과정이 완성된다.

 

물론 과정만 깔끔하다 해서 해당 방법을 채택한 것은 아니었다.

  1. 고가용성 보장
    인증 기간 생성 중 서버 장애가 발생해도, 메시지를 재처리하여 복구 가능하다.
  2. 비동기 처리로 API 성능 향상
    인증 생성은 후속 작업이므로, 클라이언트 응답 속도에 영향을 주지 않는다.
  3. 프로세스 간 결합도 감소
    상품 구매와 인증 생성 로직이 분리되어 유지보수 용이하다.

이러한 장점을 적용시키기 위해 Event를 발행하는 방법으로 개발을 진행하였다.

After

 

이렇게 리펙토링 및 정규화 과정을 거치면서 도메인전문가의 니즈를 충족시키며 기존 발생했던 문제들 혹은 불편한 점들을 개선시킬 수 있었다.

  • 고객이 구매한 상품 인증기간을 관리자가 조절할 수 있다.
  • 상품을 구매하지 않더라도 관리자가 해당 유저의 인증기간을 새로 생성 혹은 기존 인증 기간을 수정할 수 있다.
  • 생성된 인증기간들을 통해 관리자는 유저가 보유한 인증기간을 쉽게 파악할 수 있다.
  • 상품마다 인증기간이 따로 관리되기 때문에 비인증 기간을 파악하기 편하다.

그럼 이렇게만 처리하면 모든 게 다 베스트인가?

완벽하게 잘 처리한 것인가?

 

필자는 주어진 자원 내에 최적의 방법을 선택하여 과제를 해결했다 생각한다.

다만 해당 처리에도 이슈들은 분명 존재한다.

  • Kafka관련 이슈들
  • 기존 데이터 마이그레이션

하지만 이와 관련된 처리는 다른 글에서 다루도록 하고, 여기선 우선 리팩터링 하는 과정에서 했던 고민들과 어떻게 해결하였는지만 다루도록 하겠다.

 

또한, 개발자인 필자가 위와 같이 판단하였다고 맘대로 진행하면 안 된다.

도메인전문가와 기획자와의 소통은 필수 요소이며, 소통을 얼마나 잘하느냐에 따라 결과물이 달라지기 때문이다.

 

똑같은 일 두 번 하기 싫으면 소통을 자주 하는 것을 권장한다.

 

더 좋은 방법 혹은 이야기해보고 싶은 내용이 있다면 댓글 부탁드립니다.

 

 

 

 

 

 

728x90
728x90

이번 글에서 필자는 EDA 즉 Event Driven Architecture를 적용해 보면서 했던 경험들과 고민들을 작성하고자 한다.

 

해당 글을 작성하기에 앞서 알아야 하는 개념들에 대해 정리 후 본론으로 들어가 보자.

  • Event란?
  • 비동기 or 동기
  • 트랜잭션

Event란?

본글에서 말하는 Event는 도메인 모델의 상태 변화 즉 변화를 일으키는 사건을 의미한다.

 

예를 들어 상품을 주문한다 가정해 보자.

  1. 상품 재고를 확인한다.
  2. 상품 재고를 차감한다.
  3. 주문을 생성한다.
  4. 주문 완료 알림을 보낸다.

위와 같은 프로세스를 가진다고 가정한다면 상품이라는 도메인은 '상품 재고가 차감되었다.'라는 이벤트를 발행하고,

주문은 '주문이 생성되었다'라는 이벤트를 발행하게 될 것입니다.


비동기 or 동기

비동기와 동기는 통신 방식입니다.

 

동기 방식의 통신은 현재 Thread가 요청을 보낸 후 응답을 받을 때까지 대기하는 것이고,

비동기는 현재 Thread가 요청을 보낸 후 응답을 받을 때까지 대기하지 않고, 후속 작업을 콜백이나 이벤트 기반으로 처리하는 것입니다.

 

즉 작업의 완료 여부를 어떻게 처리하느냐의 차이가 있습니다.


트랜잭션

업무 처리 단위를 의미합니다.

해당 업무는 ACID를 보장해야 하며 이를 보장하는 방법이 트랜잭션입니다.

  • Atomic(원자성): 트랜잭션 내 데이터들은 데이터베이스에 모두 반영되거나 전혀 반영되지 않아야 합니다.
  • Consistency(일관성): 트랜잭션의 작업 결과는 항상 일관성을 띄워야 합니다.
  • Isolation(독립성): 각각의 트랜잭션은 독립적으로 실행되어야 합니다.
  • Durability(영구성): 트랜잭션이 성공적으로 끝났을 때 결과는 영구적으로 반영되어야 합니다.

위의 4가지 원칙을 보장하여야 합니다.

  • Active(활성): 트랜잭션이 실행 중인 상태
  • Parially Committed(부분완료): 트랜잭션 내 연산이 모두 끝났지만 반영되지 않은 상태
  • Committed(완료): 트랜잭션이 종료되어 데이터베이스에 반영된 상태
  • Failed(실패): 트랜잭션 연산 중 오류가 발생한 상태
  • Aborted(철회): 트랜잭션이 비정상 종료되어 Rollback 연산을 수행한 상태

트랜잭션은 위와 같은 5가지 상태를 가지게 됩니다.

 

특이점으로 Parially Committed에서 Aborted로 가는 경우는 연산은 끝났지만 데이터베이스에 오류가 발생 혹은 종료되어 Commit상태로 가지 못하는 상황 등이 존재합니다.


서비스 간 직접 호출 방식과 Event발행 소비 방식

앞선 예시를 가져와 주문, 상품, 알림 이 3가지 서비스를 예시로 사용하여 두 방식 간의 차이가 무엇인지 알아보겠습니다.

  • OrderService: 주문 서비스
  • ProductService: 상품 서비스
  • NotifyService: 알림 서비스

 

Event발행 방식을 알기 전 저는 서비스 간 호출을 진행할 때 아래와 같이 코드를 작성하였습니다. 

public class OrderUsecase{
    private final OrderService orderService; 
    private final ProductService productService;
    private final NotifyService notifyService;
    
    public void order(...) {
    	// 상품 재고 차감
    	productService.reduceStock(...);
        // 주문 생성
        Order order = orderService.createOrder(...);
        // 주문 알림 발행
        notifyService.sendOrderNotification(order);
    }
}

 

이와 같이 코드를 작성하더라도 문제는 없을 것입니다.

 

상품재고가 없다면 reduceStock Transaction에서 오류가 발생하여 다음 Process를 진행하지 않을 것이고,

주문이 생성되지 않는다면 알림이 발행되지 않기 때문입니다.

 

여기서 고민해 볼 사항은 '알림 발행이 실시간성을 보장해야 하고 알림 발행에 실패한 것이 주문 프로세스에 영향을 끼쳐야 하는가?'입니다.

 

물론 간단하게 처리하려면 notifyService의 sendOrderNotification메소드에 @Async 어노테이션을 붙이면 간단하게 비동기로 처리할 수 있을 것입니다.

 

하지만 여기서 고민해 볼 부분은 만약 이렇게 개발을 계속하게 된다면 어떤 불편함이 존재할까요?

 

NotifyService는 알림을 발행하는 여러 서비스에서 불리게 될 것이며 종속성을 띄게 될 것입니다.

 

그럼 우리는 이 NotifyService의 알림 발행 기능을 수정하기 위해서는 여러 서비스를 돌아다니며 수정해야 하고, 이는 기능이 추가되면 추가될수록 관리가 힘들어진다는 불편함을 낳을 것입니다.

 

또한 알림을 발행하다 실패하거나 서버가 다운되어 버리면 알림이 발행되지 않는 상황이 발생할 수 있습니다.

 

그럼 어떻게 해야 이 불편함과 예외상황을 대응할 수 있을까요?

 

서버가 의도치 않게 다운되는 현상을 JVM에 Graceful Shutdown옵션을 주면서 어느 정도 해결할 수 있습니다.

다만, 이는 서버를 종료할 때 내부 작동 중인 작업을 모두 수행한 후 종료되는 방식으로 중간에 오류가 터졌다거나 아니면 컴퓨터가 나갔다거나 하는 상황은 대응할 수 없습니다. (ex: OOM, 하드웨어 장애, kill -9)

 

이런 상황을 대응하기 위해서는 몇 가지 방법을 생각해 볼 수 있습니다.


1. DB에 발행해야 하는 알림을 저장

첫 번째로 DB에 발행해야 하는 알림을 저장하고, 스케줄링을 통해 n개의 알림을 발행하는 방법입니다.

 

문자 단체발송이나 카카오톡 단체 메시지 발송의 경우 이와 같은 방법을 많이 사용하는 것을 봤는데요.

이는 스케줄링을 통해 n개의 메시지만 처리하기 때문에 메시지 발행 서버의 부하가 일정하다는 장점이 존재합니다.

 

다만 너무 많은 알림이 저장되게 된다면, 메시지 발생시간이 지연되게 되는 단점이 존재합니다.

또한, 발행한 메시지에 대해 flag값을  변경해 주거나 데이터를 삭제해주어야 하기 때문에 DB부하가 발생할 수 있습니다.


2. Event발행

문제점 중 첫 번째 NotifyService의 호출 서비스가 많아 NotifyService의 관리가 힘들다!

 

이 부분은 Event를 발행하여 소비하는 방법으로 변경하면서 간편하게 변경할 수 있습니다.

  1. 주문 생성
  2. OrderCreated Event발행
  3. NotifyEventHandler에서 OrderCreated Event 소비
  4. NotifyService 알림 발행

Event를 발행한다면 Order와 Notiry 간의 결합이 느슨한 결합이 되어 유연성과 확장성이 좋은 형태로 변경할 수 있습니다.

 

그럼 서버가 다운되어 버린다면 알림은 서버가 다시 살아났을 때 잘 발행할 수 있을까요?

 

이벤트 스트리밍 플랫폼(kafka, net, rabbitmq 등)을 사용한다면 서버가 다운되더라도 정상적인 알림 발행이 가능합니다.

 

왜 가능할까요?

 

우선 이벤트 스트리밍 플랫폼은 따로 Instance를 필요로 합니다.

즉 이벤트 관리 서버가 존재한다는 의미이죠.

 

이벤트가 발생하면 Message를 만들어 발행하 Queue에 담아 보관을 하게 됩니다.

즉, 메인 서비스 서버가 죽었다 하더라도 이벤트를 관리하는 서버가 죽지 않는다면 얼마든지 이벤트를 소비할 수 있다는 것이죠.

 

그럼 이벤트 스트리밍 서버 여기서가 다운된다면 메시지는 사라지는 거 아닌가요?

Kafka를 예시로 들면 Kafka는 메시지를 Log Segment로 저장하므로, Kafka가 다운된다 하더라도 문제가 없습니다.

 

다만 옵션에 따라 오래된 Log는 삭제하니 이에 문제가 발생할 수 있습니다.

 

하지만 이벤트 스트리밍 플랫폼은 이벤트에 대해 고가용성을 보장하는 만큼 어느 정도 신뢰를 가질 수 있습니다.

 

그럼 문제가 다 해결될까요?

 

우리가 위에 해결하고자 하는 불편함과 문제 상황 대응은 이렇게 충분한 것 같습니다.

Kafka와 같은 이벤트 스트리밍 플랫폼을 사용할 때 발생할 수 있는 문제들이 존재하지만,

이는 아래 글에서 다루도록 하겠습니다.

 


EDA가 뭔가요?

지금까지는 Event를 발행하면 어떤 문제점 및 문제 상황을 대응할 수 있을지 알아보았습니다.

하지만 결국 Event Driven Architecture가 무엇인지는 아직 잘 와닿지는 않네요.

 

Martin Fowler가 EDA 개념을 정리하면서 나오게 되었는데요,

EDA는 쉽게 Event를 발행하고 수신자가 Event를 소비하는 형태의 시스템 아키텍처입니다.

 

EDA는 크게 3가지 구성요소를 가지게 됩니다.

  • Event generator: 표준화된 형식의 이벤트를 생성
  • Event Producer: 이벤트를 필요로 하는 시스템까지 발송
  • Event Consumer: 이벤트를 구독하고 처리  

이렇게 Event를 사용하여 통신을 하게 되었을 때 장점은 무엇이 있을 까요?

  • 비동기 처리에 용이
  • Loose coupling

등이 존재한다 생각합니다.

 

단점은 어떤 점들이 존재할까요?

  • 트랜잭션 관리의 어려움
  • 디버깅의 어려움
  • Event관리의 어려움

이러한 장단점이 존재하므로 EDA를 적용한다면 현재 서비스에 어떤 문제를 해결하기 위해 적용하는지가 명확해야 합니다.

 

그럼 비동기 개발을 하려면 그냥 EDA적용하면 되는 건가요?

아닙니다. 앞서 말했듯이 무작정 EDA를 적용한다면 Event를 받아 발행하는 Message관리도 어려울 것이고,

관심사가 명확하지 않다면 오히려 복잡한 시스템으로 탄생하게 될 것입니다.

 

그럼 언제 적용하는 것이 좋을까요?

서버 간 결합을 낮춰야 할 때, 여러 작업이 비동기로 처리되어야 할 때와 같이 어떤 문제를 해결하고자 적용하는 것이 적절하다 생각합니다.

 

실제로 EDA를 적용하여 개발하였지만, 오히려 Event들을 처리하는 Process의 복잡도에 의해 리펙토링 하는 상황도 있었고,

실시간성을 보장해야 하지만 Event를 사용하여 비동기로 처리하게 되면서 실시간성을 훼손시키는 상황도 발생하였습니다.

 

즉 EDA든 Event든 만능치료제가 아니니 꼭 알고 사용하기를 권장드립니다. 

 


References

https://medium.com/dtevangelist/event-driven-microservice-%EB%9E%80-54b4eaf7cc4a

https://jaehun2841.github.io/2019/06/23/2019-06-23-event-driven-architecture/

 

 

728x90
728x90

요즘 공고를 보거나 이름 내놓으라는 기업들을 보다 보면 MSA라는 단어를  많이 접하게 된다.

 

본인이 개발할 때도 MSA로 서비스를 분리하여 개발하고 관리하였는데 과연 MSA가 무조건 정답일까?

그럼 이전 모놀리식 아키텍처를 사용하는 것은 잘못된 개발이었을까?라는 의문이 생길 수 있다.

 

오늘 이 글은 필자가 개발하고 공부하면서 고민했던 내용들을 정리하고자 작성한다.


모놀리식 아키텍처가 뭔가요?

해당 내용을 다루기 전에 모놀리식 아키텍처가 무엇인지 알아보자.

 

모놀리식 아키텍처란 프로그램을 하나의 코드베이스에 개발하는 전통적인 아키텍처 모델을 의미한다.

 

이게 무슨 말인가?

 

우리가 서비스를 제공한다 하면 기본적으로 아래와 같은 비즈니스 기능들을 제공하게 될 것이다.

  • 회원관리
  • 게시판
  • 알림

위와 같은 기능들을 제공한다 가정할 때 하나의 애플리케이션이 모든 비즈니스 기능들을 제공하는 책임을 가지는 것이다.

 

즉 Client가 회원관리 요청을 보내든 게시판관리 요청을 보내든 알림 관련 기능 요청을 혹은 응답을 보내든 하나의 서버에서 해당 기능들을 처리하는 것을 의미한다.

 

이렇게 개발하였을 때의 장점과 단점은 무엇일까?

 

우선 장점은 이와 같이 생각해 볼 수 있다.

1. 개발 시작이 간단하다.

하나의 코드베이스에서 모두 개발하기 때문에 간단한 설계 이후 바로 개발이 가능하다.

왜냐면 어차피 하나의 코드베이스에서 개발하기 때문에 추후 리펙토링이 간단하기 때문이다.

2. 배포가 간단하다.

하나의 코드베이스에서 개발하게 된다면 하나의 애플리케이션만 관리하면 되므로 하나만 잘 관리하면 문제 될 일이 없을 것이다.

3. 기술이 통합된다.

하나의 애플리케이션에서 개발하고 관리하게 되므로 사용하는 기술이 단일화될 것이다.


그렇다면 단점은 무엇이 있을까?

1. 확장이 어렵다.

새로운 기능이 추가될 때마다 애플리케이션의 크기가 거대해질 수밖에 없다.

이렇게 된다면 추후 유지보수성을 떨어트릴 뿐만 아니라 추가 기능구현에도 제약이 발생할 확률이 높다.

2. 배포가 어렵다.

애플리케이션에 간단한 수정내용을 반영하고 배포를 하게 된다면 통째로 재배포하게 된다.

이는 애플리케이션의 크기가 작다면 괜찮겠지만 크기가 커졌을 경우 배포시간이 길어지게 되면서 리소스를 많이 소모하게 되는 현상이 발생하게 된다.

3. 기술에 제약이 생긴다.

애플리케이션에서 사용하는 기술들이 통합되다 보니 추가적인 기술 도입이나 서로 다른 언어로 개발하는 것은 매우 어려운 일이 될 것이다.

예를 들어 Java/Spring으로 개발 중인 애플리케이션에 Python/Django를 사용해야 하는 상황이 발생한다면 이는 매우 어려운 길을 걷게 될 것이다.

 

위와 같은 장단점들을 보았을 때 모놀리식 아키텍처를 사용하는 것은 대규모 프로젝트 혹은 많은 비즈니스 기능들을 처리해야 하는 상황에서는 어려운 점들이 보일 것이다.

 

다만 소규모 프로젝트나 MVP처럼 빠른 시일 내에 개발하여야 하거나 적은 비즈니스 기능들을 처리하는 애플리케이션을 개발한다 할 때에는 오히려 개발 및 관리가 편한 아키텍처라 볼 수 있다.


MSA가 뭔가요?

그럼 우리가 이번글에서 비교하고자 하는 MSA는 무엇일까?

 

MSA란 Micro Service Architecture의 약자로 직역하자면 '작은 서비스 아키텍처'이다.

 

MSA는 서비스의 크기를 작게 나누는 개념이라고 볼 수 있다.

 

우리가 모놀리식 아키텍처를 사용하면서 가장 큰 불편함이 무엇인지를 생각해 보자.

기능이 추가됨에 따라 하나의 서비스가 커져 여러 불편한 상황이 발생하는 것이 문제점이라 생각한다.

 

즉, MSA는 기존에 하나의 서비스에 다 개발하였던 비즈니스 기능들을 관심사에 맞춰 부리한 여러 개의 서비스로 나눠 관리할 수 있는 아키텍처라고 보면 된다.

모놀리식 아키텍처를 MSA로 나눠보기

 

이와 같이 모놀리식 아키텍처에서 MSA를 적용하여 서비스들을 나눠볼 수 있다.

 

이렇게 보면 MSA를 적용했을 때의 장점은 다음과 같이 생각해 볼 수 있다.

1. 독립성 및 확장성

특정 비즈니스 기능들을 묶어 개발하게 되면서 각 서비스들은 특정 관심사를 책임지게 된다.

이는 각각의 서비스가 담당하는 비즈니스 기능들이 명확해지면서 추후 오류 추적이나 디버깅이 수월해질 수 있다.

 

또한 기능을 추가하더라도 각각의 관심사에 맞는 서비스에 기능을 추가하면 되므로 확장성과 유지보수성이 좋다.

2. 스케일의 용이

모놀리식의 경우 하나의 서비스를 관리하기 때문에 대용량 트래픽 혹은 대용량 데이터 처리와 같은 과제를 만나게 된다면 스케일 아웃과 같은 방법으로 처리하는 것은 매우 비효율적인 상황이 발생할 것이다.

 

하지만, MSA로 분리한 경우 대용량 트래픽이 몰리는 서비스만 스케일 업이나 스케일 아웃을 통해 해결하면 되므로 MA에 비해 컨트롤할 수 있는 방법이 많아진다.

3. 배포가 쉽다.

간단한 수정사항이 발생하였을 경우 해당 수정이 발생한 애플리케이션만 재배포를 진행하면 되므로 재배포에 대해 MA에 비해 적은 부담을 가지게 된다.

4. 기술적 제약이 적다

각각의 서비스가 물리적으로 분리되어있다 보니 특정 서비스의 기술을 다르게 개발할 수 있는 유연함이 생긴다.

 

즉 Java로 구현하든 Python으로 구현하든 Javascript로 구현하든 서로 영향을 미치는 범위가 적어 유연한 개발이 가능하다는 의미다.


장점이 있다면 단점 또한 존재하는 법 어떤 단점이 존재할까?

1. 설계의 복잡도

만약 처음부터 MSA를 적용하여 개발을 하게 된다면 어떤 기준으로 서비스들을 나누어야 하는지 설계의 고민이 필요하다.

설계가 잘못되어 하나의 서비스가 의도와 다르게 거대해진다면 이는 MSA를 적용하였지만 각각의 서비스는 MA를 적용하여 개발하는 것과 다를게 없어진다 생각한다.

2. 분산 시스템의 복잡도

여러 서비스로 나누다 보니 각각 어떤 통신을 하는지, 오류가 발생했을 때 해당 오류를 찾기 위해 여러 서비스를 확인해야 한다는 단점이 존재한다.

 

또한, 테스트를 진행할 때 특정 비즈니스 기능들이 모여있어 해당 기능들을 테스트하기는 수월하지만, 서비스 간 통신을 통한 비즈니스 기능들은 테스트하기 어려움이 존재한다.

 

트랜잭션 관리에 있어 어려움이 있으며, 이를 해결하기 위해 보상 트랜잭션과 같은 기능들이 구현해야 하기 때문에 자연스레 개발 난이도 및 유지보수 난이도가 올라간다.

3. 인프라 구축의 난이도 상승

각 서비스를 어떻게 관리할 것인지 또한 어떻게 배포할 것인지 각 서비스 간 통신은 어떻게 할 것인지에 대한 인프라가 구축되어야 한다.

 

이는 MA의 단순한 인프라에 비해 많은 리소스를 필요로 할 것이다.

 

이렇듯 MSA 또한 장점만 가지고 있는 것이 아닌 단점 또한 가지고 있다.


MSA 해보니 어땠나?

MSA를 경험하며 다양한 고민과 배움을 얻을 수 있었다.

 

혼자 사이드 프로젝트를 진행하면서 대규모 서비스들을 개발하는 경험은 쉽지 않다 생각한다.

현장에서 MSA로 구축된 프로젝트를 진행하면서 각 서비스를 나누는 기준과 어떤 어려움이 존재하는지를 경험할 수 있는 기회는 매우 소중한 경험이었다.

 

다만 무엇이든 잘못 설계하면 그렇듯이 너무 잘게 나누어 "굳이 나눌 필요가 있을까?"와 같은 고민들을 하게 될 수 있으며,

각 서비스 간 통신 방법에 대하여 동기와 비동기방식 중 어떤 것을 사용할지에 대해 고민하게 될 것이다.

 

처음부터 MSA를 도입하는 것은 어려운 선택일 수 있다.

먼저 모놀리식 아키텍처를 경험하며 그 한계를 느끼고, 이를 해결하는 과정에서 MSA를 도입하는 것이 더 좋은 학습 과정이 될 것이다.

 

처음에는 'MSA가 이미 적용되어 있으니 그대로 쓰면 되겠지'라는 생각을 했지만,

이는 왜 MSA로 구축하였는지에 대한 질문에 답변을 내놓지 못하는 상황이 발생할 수 있다.

 

MA와 MSA에 잘못된 아키텍처는 없다.

다만 "어떤 상황과 문제를 만났기 때문에 어떤 아키텍처를 적용하였다."를 답변하지 못한다면,

이는 스스로 잘못된 방향으로 경험을 쌓았다고 말하고 싶다.

 

아키텍처는 단순히 잘 돌아가는 것이 목표가 아니다.

주어진 환경에서 최적의 해결책을 찾기 위해 끊임없이 고민하고 실천하는 것이 진정한 개발자의 길이라 생각한다.

 

728x90
728x90

최근 아키텍처 공부를 하면서 흔히 접할 수 있는 Clean ArchitectureHexagonal Architecture를 만나게 되었습니다.

 

여러 블로그들과 글들을 읽으면서 Clean Architecture와 Hexagonal Architecture의 다른 점이 무엇인지 점점 모호해지는 것을 느꼈습니다.

 

왜냐면 두 아키텍처에서는 Entity를 중심에 두고 그다음 UseCase를 통해 로직을 정의 한 다음 그다음 DIP를 적용하여 의존성을 내부 Core로 의존관계를 형성하면서, 외부의 변경 ( API변경, DB변경 등 )으로부터 내부 구현체들은 안전하다는 장점을 띄고 있기 때문입니다.

 

그렇다면 두 아키텍처는 어떤 점에서 차이를 보이고 있는지 알아보도록 하겠습니다.


Clean Architecture - Robert C. Martin

클린 아키텍처를 한 번이라도 보았다면 많이 익숙한 그림일 것입니다.

해당 그림은 의존관계를 내부로 의존하도록 하는 것을 가시화하여 보여주고 있습니다.

 

그림과 같은 의존관계를 형성한다면 파란색 원 즉 UI, Web, DB와 같은 외부 시스템 변경이 있다 하더라도 Application내부에는 영향이 없는 관계를 형성할 수 있습니다.

 

즉, 우리가 보호하려 하는 도메인의 정책 및 비즈니스 규칙들이 보호된다는 강점을 가질 수 있습니다.

 

클린 아키텍처에서는 다음과 같은 특징을 가집니다.

  • 프레임워크에 독립적이다.
  • 비즈니스 로직들 즉 내부 로직들은 외부 통신 없이 테스트 가능하다.
  • 내부 영향 없이 쉽게 외부 시스템을 변경할 수 있다.
  • 데이터베이스 종류와 관계없이 변경할 수 있다.
  • 외부 시스템에서는 Application 내부의 일에 대해 알 수 없다.

이러한 특징들은 외부와 분리된 흔히 모듈화 된 Application를 만들 수 있다는 점이 있습니다.

 

그럼 클린 아키텍처에서는 어떤 점을 중요하게 보아야 할까? 바로 종속성 규칙을 가장 중요하게 보면 될 거 같습니다.

종속성 규칙이 유효하다면 위 그림에 존재하는 원의 개수가 달라진다 하더라도 클린 아키텍처를 따르고 있다고 볼 수 있습니다.

 

다만, 종속성 규칙을 위배한다면 클린 아키텍처를 온전히 적용하였다고 보기는 어려울 것 같습니다.


Hexagonal Architecture - Alistair Cockburn

해당 아키텍처는 2005년에 처음 소개하면서 "Ports and Adapters"아키텍처라고 불렸습니다.

처음 소개 된 이름을 보면 감이 오시겠지만 핵사고 날 아키텍처에서 가장 중요한 개념은 Ports와 Adapters입니다.

 

Ports는 시스템이 외부와 통신하는 인터페이스를 정의하며,

AdaptersPorts와 외부 시스템 간의 실제 구현을 담당합니다.

 

즉, 이 또한 DIP를 활용하여 Application에서 외부에 의존하지 않고, Ports라는 Interface를 제공함으로써 Application이 하나의 모듈화 되는 아키텍처입니다.

 

그렇다면 왜 이 아키텍처 이름은 "Hexagonal Architecture"라 불리게 되었는지 의문이 들게 됩니다.

왜 "Hexagonal"인가?

  1. 다양한 외부 인터페이스 표현
    육각형은 여러 방향에서 포트를 연결할 수 있는 모양을 상징합니다. 이는 애플리케이션이 다양한 외부 시스템과 상호작용할 수 있다는 점을 강조합니다.
  2. 대칭성
    육각형의 대칭성은 애플리케이션이 외부 시스템과의 관계에서 특정 방향에 종속되지 않고, 내부 도메인 로직이 외부 의존성에서 독립적이어야 한다는 철학을 나타냅니다.
  3. 시각적 단순화
    Cockburn은 육각형이 포트와 어댑터를 배치하는 데 직관적인 도형이라고 보았습니다. 이 도형은 외부 시스템과 내부 도메인 간의 관계를 명확히 표현할 수 있습니다.

위와 같은 이유로 "Hexagonal Architecture"로 불리고 있습니다.

 

그럼 위 그림을 보면 Adapters두 육각형을 연결하는 역할 즉, 실질적인 구현체라는 것은 알겠지만,

Ports는 어디에 있는 걸까요?

 

그림을 자세히 보면 내부 육각형의 면이 굵은 것을 확인할 수 있습니다.

바로 이 육각형의 면이 외부 시스템과 연결을 위해 제공되는 Interface 즉, Ports입니다.

 

Hexagonal Architecture는 추후 Clean Architecture와 Onion architecture의 철학에 영향을 미치게 됩니다.


Clean Architecture와 Hexagonal Architecture의 차이

두 아키텍처는 기본적으로 같은 목표를 가진 설계 철학을 가지고 있어 혼동하기 쉽습니다.

 

그게 그거 아닌가!라고 생각 할 수 있지만, 딱 잡고 말하자면

Clean Architecture는 애플리케이션을 여러 계층으로 나누고, 의존성 방향을 바깥에서 안쪽으로 향하는 아키텍처이고,

Hexagonal ArchitecturePortsAdapters를 통해 외부 시스템과 내부 시스템을 분리하여 의존 방향이 Application 내부로 향하는 아키텍처입니다.

 

 즉, 두 아키텍처는 유지보수성과 확장성을 높이고, Core를 외부 시스템으로부터 분리하는데 초점을 맞춘다는 공통점이 존재합니다.

 

그럼 어떤 아키텍처를 사용하는 것이 좋을까요?

 

만약 애플리케이션의 종속성 규칙을 엄격하게 지키고 싶다면 Clean Architecture를,

외부 시스템과 내부 시스템의 엄격한 분리를 원한다면 Hexagonal Architecture를,

만약 둘 다 엄격하게 가져가고 싶다면 섞어 사용하면 될 것 같습니다.

 

결국 우리가 이야기하는 아키텍처는 하나의 시스템을 설계하는 방법일 뿐 상황에 맞춰 최선의 방법으로 설계하는 것이 가장 중요하다 생각합니다.

 

물론, 다른 개발자들을 설득하는 것은 본인의 몫이라는 것은 비밀입니다. 


Reference

Clean Architecture

Hexagonal Architecture

 

728x90

'Server' 카테고리의 다른 글

[EDA] EDA는 왜 적용하게 된걸까?  (0) 2025.04.03
[MSA] 왜 MSA로 가야하나요?  (0) 2025.04.03
[DNS] 너의 주소는?  (6) 2024.12.30
[ JPA ] OSIV가 뭔가요?  (1) 2024.11.12
URL로 어떻게 사이트를 접속할 수 있을까?  (10) 2024.10.23
728x90

우리가 웹브라우저 주소창에 도메인 주소(ex. www.naver.com)를 입력하고 나면, 

특정 사이트로 페이지를 이동하는 것을 확인할 수 있습니다.

 

하지만 개발을 하다 보면 도메인 주소 즉 사이트의 이름으로는 사이트가 인터넷이라는 바다의 어디에 있는지 알 수 있는 방법이 없습니다.

 

그렇다면 우리는 어떻게 해당 사이트를 찾아갈 수 있는 것일까요??

 

오늘은 사이트 이름을 가지고 해당 사이트의 주소를 찾아가는 이야기를 다뤄보려 합니다.


Domain과 IP의 개념

우선 우리가 왜 주소를 찾아야 하는지 알기 위해서는 Domain IP가 무엇을 의미하는지 아는 것부터 시작할 수 있습니다.

 

흔히 우리는 친구 집을 찾아간다 할 때 뭐라고 부르나요?

##네 집이라고 부르지 않나요??

 

이때 고민해봐야 하는 점은 왜 우리는 친구의 집서울특별시...라고 자세하게 말하지 않고 ##네 집이라고 부르는 것일까요?

 

여러 이유가 있겠지만 이미 어디인지 알기 때문에 간단하게 기억할 수 있는 이름으로 기억하는 것이 가장 크지 않을까요?

 

이처럼 우리는 이미 특정 위치를 말할 때는 부르기 쉬운 것으로 기억한다는 것을 알 수 있습니다.

 

그럼 다시 주제로 돌아와 IP는 어떤 것일까요?

IP는 인터넷에 특정 위치에 있는 사이트의 주소를 나타내게 됩니다.

 

그렇다면 Domain는 어떤 것일까요?

Domain는 사이트의 주소를 부르기 쉬운 명칭이라 볼 수 있습니다.

 

이로써 가벼운 개념은 정리가 된 것 같네요.

이 글에서 IP는 친구집 주소, Domain은 친구집 호칭으로 봐주시면 좋을 것 같습니다.


IP를 찾기 위한 모험

그렇다면 만약 전학생의 집에 놀러 가고 싶어 주소가 필요하다면 어떻게 해야 할까요?

 

우리는 이미 전학생네 집이라는 Domain을 가지고 있습니다.

만약 학교라면 선생님께 여쭤보고, 선생님이 모른다면 전산시스템에 들어갈 것이고, 이도 모른다면 못 찾아갈 것입니다.

또한, 만약 주소가 잘못되었다면 우리는 결국 찾아가지 못할 것입니다.

 

이러한 시스템은 인터넷에서도 동일한데요.

우리가 사용하는 컴퓨터에 hosts라는 파일은 우리가 Domain을 방문하고 싶을 때 해당 IP정보를 가지고 있는 파일입니다.

하지만, 여기에 존재하지 않을 때 우리 컴퓨터는 누구에게 물어볼 수 있을 까요?

1. Caching Resolver

캐싱 리졸버는 사용자의 로컬 네트워크에 있는 서버이며, 우리가 사이트에 한번 들어가게 되면 해당 IP와 Domain은 Caching Resolver에 저장되어 다음번 방문 때 훨씬 빠르게 도와주는 서버입니다.

 

즉 우리가 빠르게 접근할 수 있는 긴급연락망이 되겠네요!

2. Local DNS

해당 서버는 기지국 DNS라고도 부르며, 우리가 흔히 사용하는 통신사(SKT, KT, LG 등...)를 의미합니다.

 

로컬 PC에서 사이트 IP를 찾지 못한 경우 Local DNSIP를 요청하게 되며, 존재하지 않을 경우 다음 단계로 넘어가게 됩니다.

 

해당 서버는 교무실이라고 볼 수 있겠네요!

3.  Root DNS

이런!! 교무실에 방문했더니 다른 학년이라 학생 정보가 없는 걸까요?!

 

그럼 우린 더 많은 정보를 접할 수 있게 교육청에 찾아가도록 하죠!

Root DNS는  ICANN이 관리하는 DNS시스템의 최상위 계층에 존재하는 서버입니다.

 

Root DNS에서 우리가 원하는 전학생네 집 주소가 있는지 확인합니다.

이때! 있으면 정말 다행이지만, 없다면 동사무소에 찾아가 보기로 합니다.

4. TLD( Top-Level-Domain ) DNS

여기서 동사무소"www.naver.com"에 접속한다 하면,

맨 뒤에 작성된 ". com"을 소유한 주소들을 관리하는 서버입니다. 

 

즉, ". com"를 소유한 주소들을 가진 서버에서 확인한다는 의미입니다.

 

TLD DNS에서 DomainIP를 찾아보고 없다면 하위 Second Level Domain DNS로 넘겨 최종적으로 www.naver.com  Domain DNS Authoritative DNS Server에 접근하여 IP를 얻어낼 수 있습니다.

 

그럼 우리는 모든 상황에서 전학생네 집을 찾았을까요??

아쉽게도 하나의 가정이 더 남아있네요...

 

만약, 동사무소도 주소를 모른다면 어떻게 할까요?

 

물론 이미 해당 레벨에서 한 번이라도 사람이 방문한 적 있다면 주소를 얻을 수 있겠지만,

만약 없다면 친구는 무인도에 살고 있을 확률이 높으므로 다음날 학교에서 물어보도록 합시다.


여행의 끝

우리는 위의 전학생 주소 찾기를 통해 어떻게 주소를 찾아야 할지를 비교적 간단하게 알아보았습니다.

 

추가적인 내용으로는 국가 코드, 일반 코드 등 상세하게 나눠지는 부분은 당연히 존재합니다.

하지만, 이 글의 목적은 어떤 흐름으로 Domain으로 IP를 가져오는지에 대한 니즈를 해결하기 위한 글이므로 다음 고급 편에서 더욱 세세하게 다뤄보도록 하겠습니다.

 

이렇게 IP를 가져왔다면 IP를 해석하며 서버를 찾아가는 과정도 존재하겠죠?

해당 내용은 "너에게 닿기를"이란 주제로 찾아뵙겠습니다.


Reference

https://www.cloudflare.com/ko-kr/learning/dns/what-is-dns/

https://aws.amazon.com/ko/route53/what-is-dns/

DNS 트래픽 쿼리 관련 논문: https://arxiv.org/pdf/2308.07966

 

https://namu.wiki/w/%EB%84%88%EC%9D%98%20%EC%9D%B4%EB%A6%84%EC%9D%80.

 

너의 이름은.

아직 만난 적 없는 너를, 찾고 있어. まだ会ったことのない君を、探している。 캐치프레이즈 2016년 8월 26일

namu.wiki

 

728x90

+ Recent posts