배타적 서브타입(Exlusive Subtype) 테이블 설계와 백엔드 구현

배타적 서브타입(Exlusive Subtype) 테이블 설계와 백엔드 구현

배타적 서브타입이란?

데이터베이스 테이블 설계에서 슈퍼타입(Super Type)과 서브타입(Sub Type)은 공통 속성을 기준으로 엔티티(테이블)를 상위/하위로 나누는 데이터 모델링 기법입니다.

  • Super Type (상위 엔티티)
    • 여러 엔티티가 공통으로 가지는 속성을 모아 놓은 엔티티
    • 식별자(PK, ID)를 포함한 공통 정보를 가지고 있음
  • Sub Type (하위 엔티티)
    • 각 타입만의 고유한 속성을 추가로 가지는 엔티티

제조실행시스템(MES)를 위한 테이블을 설계 할 때를 예시로 왜 테이블을 Super Type과 Sub Type으로 나누는 지 살펴보겠습니다.

대표적으로 공장에서 제조를 할 때 사용되는 ITEM(품목)은 여러 종류가 있습니다.

  • 제품(최종 제품)
  • 반제품(제품을 생산 할 때 다른 필요한 중간 생산 제품)
  • 자재
  • 공구

위 현실세계의 제품을 엔티티로 모사해서 테이블 화 할 때 만약 위 다양한 종류를 하나의 엔티티로 관리한다면 NULL 컬럼이 급증하게 됩니다.

  • NULL 컬럼이 급증하는 이유?
    • 공구는 수명 및 점검주기 컬럼이 필요합니다.
    • 자재는 자재에만 적용되는 스펙, 자재검사의 유무 등의 컬럼이 필요합니다.
    • 제품은 제품의 최종적인 처리 방식을 나타내는 컬럼이 필요합니다.

위의 경우 하나의 테이블로 ITEM을 관리하게 되면 공구용 컬럼에는 자재, 제품 등의 데이터는 모두 NULL로 저장되며 반대의 경우도 발생합니다.

그렇다면 위 ITEM 별 종류들을 모두 별개의 엔티티로 관리하면 되지 않을까요? 그런 경우 다음과 같은 단점이 발생합니다.

  • 공통속성이 여러 테이블에 중복됩니다.
    • 제품/반제품/자재/공구 등은 모두 ITEM의 성격을 가지고 있어서 공통적으로 가지는 속성 예) 코드, 이름, 사용여부, 생성일, 수정일 등 이 테이블마다 중복으로 저장됩니다.
      • 이 경우 공통 속성 컬럼 정의 수정 시 예)코드의 길이 변경 시 모든 종류의 ITEM 테이블 정의를 변경해야 합니다.
    • ITEM이라는 공통 관점에서의 조회/참조가 어려워집니다.
      • 만약 ITEM이라는 공통 속성으로만 조회를 할 경우, 테이블이 완전 분리되어 있으면 품목 검색 시 (공통컬럼만 필요한 경우에도) 제품/반제품/자재/공구 를 각 각 조회하여 UNION ALL 로 합쳐야 합니다.
      • 또한 ITEM이라는 공통의 개념과 연관된 테이블을 설계할 때 다 수의 제품/반제품/자재/공구 테이블과 각각 PK/FK 관계를 맺을 필요 없이 ITEM 슈퍼타입의 ITEM_ID를 FK로 한번 참조할 수 있습니다.

이런 단점들을 보완하기 위해 Super Type으로 ITEM(품목) 엔티티를 설정하고 Sub Type으로 각 각 제품, 반제품, 자재, 공구 등의 엔티티를 설계할 수 있습니다. 이 경우 공통 컬럼은 Super Type인 ITEM(품목) 엔티티에 설정하고 각 Sub Type의 고유 특성들을 각 각의 엔티티에 설정해서 관리 해주면 됩니다.

그런데 배타적 - exclusive 라는 말이 왜 붙은 걸까요?

여기서 배타적이라는 의미는 ITEM(품목) 이 무조건 제품, 또는 자재 등의 하위 성격중에서 하나만 가지고 있어야 된다는 의미입니다. 만약 ITEM_ID 로 ITEM_001이라는 데이터가 제품이자 자재여서 제품 Sub-Type entity에도 저장되고, 자재 Sub-Type entity에도 저장되면 안된다는 의미 입니다.

만약 ITEM_ID = 'ITEM_001' 인 데이터가
PRODUCT 테이블과 MATERIAL 테이블에 동시에 존재한다면,
해당 ITEM은 “제품이면서 자재”가 되어버리며
이는 우리가 정의한 품목의 도메인 규칙을 명확히 위반하는 상태가 됩니다.

이처럼 서브타입 간 중복을 허용하지 않고,
반드시 하나의 하위 성격만 가질 수 있도록 제한하는 구조를
배타적(Exclusive) 서브타입이라고 부릅니다.

이처럼 배타적으로 설계 하지 않을 경우에는 아래와 같은 단점이 존재합니다.

  1. 도메인 의미가 깨집니다.
    1. MES 관점에서 제품, 자재, 공구는 역할과 책임이 전혀 다른 개념입니다.
      1. 제품은 생산의 결과물이며 출하 대상이 되고
      2. 자재는 생산에 투입되어 소모되며
      3. 공구는 수명을 관리하며 반복 사용됩니다.
    2. 그런데 하나의 ITEM이 제품이면서 동시에 자재로 존재한다면, 이 ITEM이 출하대상인지 소모대상 인지 명확하지 않게 됩니다.
  2. 분기문이 복잡해집니다.
    1. 만약 ITEM이 여러 Sub Type에 동시에 존재 할 수 있다면, 백엔드 로직은 항상 다음과 같은 방어 코드를 작성해주어야 합니다
      1. 이 ITEM은 제품이기도 하고 자재이기도 합니다.
      2. 이 ITEM을 어떤 기준으로 제품으로 처리할 지, 자재로 처리할 지 모호합니다.
      3. 결국 코드 곳곳에 아래와 같은 분기문이 생기게 됩니다.
if (isProduct && isMaterial) {
    ...
}

배타적 서브타입 설계 예시

그럼 실제로 배타적 서브타입 설계 + 백엔드 구현을 해보겠습니다.

데이터베이스 구현

먼저 테스트 환경은 데이터베이스 서버는 MS SQL SERVER 15버전이며 데이터베이스 관리 및 쿼리 툴은 SQL Server Management Studio 18.12.1 버전 입니다.

테스트용 데이터데이스를 생성해보겠습니다.

생성된 데이터베이스에 각 각 Super Type으로 ITEM(품목)을 Sub Type으로 PRODUCT(제품), MATERIAL(자재) 테이블을 추가 해줍니다.

  • Super Type
    • ITEM(품목)
      • ITEM_ID (PK)
      • ITEM_TYPE (타입)
      • ITEM_ATT (공통속성)
  • Sub Type
    • PRODUCT(제품)
      • PRDT_ID (PK,FK)
      • PRDT_TYPE (타입)
      • PRDT_ATT (고유속성)
    • MATERIAL(자재)
      • MAT_ID (PK,FK)
      • MAT_TYPE (타입)
      • MAT_ATT (고유속성)

테이블 생성 및 연관관계 설정은 데이터베이스 관리 툴에서 -> 데이터베이스 다이어그램 기능(우클릭-새 데이터베이스 다이어그램)을 이용해서 생성하겠습니다.

다이어그램 창 -> 우클릭 -> 새 테이블

컬럼을 작성해줍니다.
PK 적용할 컬럼 우클릭 -> 기본키 설정
3개의 테이블 생성된 모습

이제 연관관계를 설정해줍니다. Sub Type의 PK인 PRDT_ID와 MAT_ID의 FK를 ITEM의 PK로 지정합니다.

먼저 ITEM 테이블의 PRIMARY KEY 칼럼인 ITEM ID의 열쇠 아이콘을 마우스 좌 클릭 후 PRODUCT 테이블의 PRIMARY 칼럼인 PRDT_ID 칼럼으로 끌어다 놓습니다.

MATERIAL 테이블도 똑같이 해줍니다.

Super Type과 Sub Type의 1:1 관계 매핑 확인

해당 다이어그램을 저장(ctrl + s)하여 실제 테이블이 생성됨을 확인합니다.

주의: 운영환경의 데이터베이스에서 다이어그램툴을 이용하여 수정, 생성 후 저장은 실제로 데이터베이스에 반영이 되므로 주의해야 합니다.

생성된 테이블의 CREATE 문을 살펴보겠습니다.

  • Super Type
USE [exclusive_test]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[ITEM](
	[ITEM_ID] [nvarchar](50) NOT NULL,
	[ITEM_TYPE] [nvarchar](50) NOT NULL,
	[ITEM_ATT] [nvarchar](50) NOT NULL,
 CONSTRAINT [PK_ITEM] PRIMARY KEY CLUSTERED 
(
	[ITEM_ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

단순화 버전

CREATE TABLE ITEM (
    ITEM_ID   NVARCHAR(50) PRIMARY KEY,
    ITEM_TYPE NVARCHAR(50) NOT NULL,
    ITEM_ATT  NVARCHAR(50) NOT NULL
);
  • Sub Type
USE [exclusive_test]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[MATERIAL](
	[MAT_ID] [nvarchar](50) NOT NULL,
	[MAT_TYPE] [nvarchar](50) NOT NULL,
	[MAT_ATT] [nvarchar](50) NOT NULL,
 CONSTRAINT [PK_MATERIAL] PRIMARY KEY CLUSTERED 
(
	[MAT_ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[MATERIAL]  WITH CHECK ADD  CONSTRAINT [FK_MATERIAL_ITEM] FOREIGN KEY([MAT_ID])
REFERENCES [dbo].[ITEM] ([ITEM_ID])
GO

ALTER TABLE [dbo].[MATERIAL] CHECK CONSTRAINT [FK_MATERIAL_ITEM]
GO

단순화 버전

CREATE TABLE MATERIAL (
    MAT_ID   NVARCHAR(50) PRIMARY KEY,
    MAT_TYPE NVARCHAR(50) NOT NULL,
    MAT_ATT  NVARCHAR(50) NOT NULL,
    CONSTRAINT FK_MATERIAL_ITEM
        FOREIGN KEY (MAT_ID)
        REFERENCES ITEM (ITEM_ID)
);


백엔드 구현

이제 해당 데이터베이스 테이블들에 데이터를 저장, 조회 하는 백엔드 예시 코드를 구현해보겠습니다.

먼저, 우리가 구현한 데이터베이스 테이블 설계에서 한가지 의문점을 가져야 합니다.

과연 저 데이터베이스 테이블 설계 구조에서 위에서 언급된 배타적 특성이 보장되는지 입니다.

결론적으로 지금 상태에서는 배타적 특성이 보장되지 않습니다. 예시로 위 설계된 테이블에 데이터를 넣어 보겠습니다.

정상 케이스

-- 슈퍼타입 생성
INSERT INTO ITEM (ITEM_ID, ITEM_TYPE, ITEM_ATT)
VALUES ('ITM001', 'PRDT', 'COMMON');

-- PRODUCT 서브타입 생성 (OK)
INSERT INTO PRODUCT (PRDT_ID, PRDT_TYPE, PRDT_ATT)
VALUES ('ITM001', 'PRDT', 'PRDT');

배타성이 깨진 케이스

INSERT INTO MATERIAL (MAT_ID, MAT_TYPE, MAT_ATT)
VALUES ('ITM001', 'MAT', 'MAT');

조회 해봅니다.

select * from ITEM
select * from PRODUCT
select * from MATERIAL
ITEM001이 제품이자 자재가 되었습니다.

왜 이런 결과가 발생할까요?

  • FOREIGN KEY는
    → “부모가 존재하는지”만 검증한다.
  • PRIMARY KEY는
    → “중복되지 않는지”만 보장한다.

하지만 DB 제약만으로는,

  • “이 ITEM_ID가 어느 Sub Type에 속하는지”
  • “이미 다른 Sub Type에 속해 있는지”

와 같은 도메인 규칙까지 표현하기는 어렵습니다.

물론, 트리거(trigger)나 복잡한 제약조건을 걸어 일부 제어는 가능하지만 실무에서는 배타성의 최종 책임을 백엔드 애플리케이션 이 가져가는 경우다 대부분입니다.

이에 따라 백엔드 구현 예시는 어떻게 배타성적용하는지의 관점으로 작성되었습니다.

  • 백엔드 환경
    • java 17
    • spring boot 3.5.10
spring.application.name=exclu_demo

#Hikari CP
spring.datasource.max-lifetime=1800000
spring.datasource.maximum-pool-size=20

#JPA
spring.jpa.hibernate.ddl-auto=none

# MS-SQL config
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
spring.datasource.url=jdbc:sqlserver://127.0.0.1;databaseName=exclusive_test;trustServerCertificate=true
spring.datasource.username=plato
spring.datasource.password=

먼저 DB 테이블 매핑하는 코드를 작성하겠습니다.
entity 폴더를 만들고 우리가 생성한 테이블과 매핑 되는 Item Product Material 코드를 작성합니다.

package plato.io.exclu_demo.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.domain.Persistable;

import java.io.Serializable;

@Entity
@Table(name = "ITEM")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Item implements Persistable<String>, Serializable {

    @Id
    @Column(name = "ITEM_ID", length = 50 )
    private String itemId;

    @Enumerated(EnumType.STRING)
    @Column(name = "ITEM_TYPE", length = 50)
    private ItemType itemType;

    @Column(name = "ITEM_ATT", length = 50)
    private String itemAtt;

    @Override
    public String getId() {
        return itemId;
    }

    @Override
    public boolean isNew() {
        return false;
    }

    @Setter
    @Transient
    @Builder.Default
    private boolean isNew = false;
}
package plato.io.exclu_demo.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.domain.Persistable;

import java.io.Serializable;

@Entity
@Table(name = "PRODUCT")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product implements Persistable<String>, Serializable {
    @Id
    @Column(name = "PRDT_ID", length = 50)
    private String prdtId;

    @Enumerated(EnumType.STRING)
    @Column(name = "PRDT_TYPE", length = 50)
    private ItemType prdtType;

    @Column(name = "PRDT_ATT", length = 50)
    private String prdtAtt;

    // 부모(Item) 참조 - FK
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PRDT_ID", referencedColumnName = "ITEM_ID", insertable = false, updatable = false)
    private Item item;

    @Transient
    @Builder.Default
    private boolean isNew = false;

    @Override
    public String getId() {
        return prdtId;
    }

    @Override
    public boolean isNew() {
        return isNew;
    }

}
package plato.io.exclu_demo.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.domain.Persistable;

import java.io.Serializable;

@Entity
@Table(name = "MATERIAL")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Material implements Persistable<String>, Serializable {

    @Id
    @Column(name = "MAT_ID", length = 50)
    private String matId;

    @Enumerated(EnumType.STRING)
    @Column(name = "MAT_TYPE", length = 50)
    private ItemType matType;

    @Column(name = "MAT_ATT", length = 50)
    private String matAtt;

    // 부모(Item) 참조 - FK
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MAT_ID", referencedColumnName = "ITEM_ID", insertable = false, updatable = false)
    private Item item;

    @Transient
    @Builder.Default
    private boolean isNew = false;

    @Override
    public String getId() {
        return matId;
    }

    @Override
    public boolean isNew() {
        return isNew;
    }
}

ItemType enum 클래스도 생성해줍니다.

package plato.io.exclu_demo.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ItemType {
    PRODUCT("P", "제품"),
    MATERIAL("M", "자재");

    private final String prefix;      // ID prefix
    private final String description; // 설명
}

그 다음 repository 계층을 인터페이스로 선언해줍니다.

package plato.io.exclu_demo.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import plato.io.exclu_demo.entity.Item;

import java.util.List;

@Repository
public interface ItemRepository extends JpaRepository<Item, String> {
}
package plato.io.exclu_demo.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import plato.io.exclu_demo.entity.Product;

@Repository
public interface ProductRepository extends JpaRepository<Product,String> {
}
package plato.io.exclu_demo.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import plato.io.exclu_demo.entity.Material;

@Repository
public interface MaterialRepository extends JpaRepository<Material,String> {
}

다음으로 ITEM 생성시 필요한 DTO 구조를 작성합니다.

package plato.io.exclu_demo.dto;

import lombok.*;
import plato.io.exclu_demo.entity.ItemType;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ItemSaveRequest {
    // 공통 (ITEM)
    private ItemType itemType;
    private String itemAtt;

    // 제품 (PRODUCT)
    private String prdtAtt;

    // 자재 (MATERIAL)
    private String matAtt;
}
package plato.io.exclu_demo.dto;

import lombok.Builder;
import lombok.Getter;
import plato.io.exclu_demo.entity.Item;
import plato.io.exclu_demo.entity.ItemType;

@Getter
@Builder
public class ItemSaveResponse {
    private String itemId;
    private ItemType itemType;
    private String itemAtt;

    public static ItemSaveResponse from(Item item) {
        return ItemSaveResponse.builder()
                .itemId(item.getItemId())
                .itemType(item.getItemType())
                .itemAtt(item.getItemAtt())
                .build();
    }
}

다음으로 실제 ITEM/제품/자재를 저장, 조회하는 service 계층입니다.

package plato.io.exclu_demo.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import plato.io.exclu_demo.dto.ItemSaveRequest;
import plato.io.exclu_demo.dto.ItemSaveResponse;
import plato.io.exclu_demo.entity.Item;
import plato.io.exclu_demo.entity.ItemType;
import plato.io.exclu_demo.entity.Material;
import plato.io.exclu_demo.entity.Product;
import plato.io.exclu_demo.repository.ItemRepository;
import plato.io.exclu_demo.repository.MaterialRepository;
import plato.io.exclu_demo.repository.ProductRepository;


@Service
@RequiredArgsConstructor
@Transactional
public class ItemService {
    private final ItemRepository itemRepository;
    private final ProductRepository productRepository;
    private final MaterialRepository materialRepository;

    /**
     * ITEM_TYPE에 따라 ID 자동 생성
     */
    private String generateId(ItemType itemType) {
        long count = switch (itemType) {
            case PRODUCT -> productRepository.count();
            case MATERIAL -> materialRepository.count();
        };
        return itemType.getPrefix() + String.format("%05d", count + 1);
    }

    /**
     * 품목 저장 - ITEM_TYPE에 따라 서브타입 자동 생성
     */
    public ItemSaveResponse saveItem(ItemSaveRequest request) {
        ItemType itemType = request.getItemType();

        // 1. ID 자동 생성
        String itemId = generateId(itemType);

        // 2. Super Type 생성
        Item item = Item.builder()
                .itemId(itemId)
                .itemType(itemType)
                .itemAtt(request.getItemAtt())
                .build();
        item.setNew(true);

        // 3. Item 먼저 저장
        itemRepository.save(item);

        // 4. ITEM_TYPE에 따라 Sub Type 생성 및 저장 (배타적)
        switch (itemType) {
            case PRODUCT -> {
                Product product = Product.builder()
                        .prdtId(itemId)
                        .prdtType(itemType)
                        .prdtAtt(request.getPrdtAtt())
                        .build();
                product.setNew(true);
                productRepository.save(product);
            }
            case MATERIAL -> {
                Material material = Material.builder()
                        .matId(itemId)
                        .matType(itemType)
                        .matAtt(request.getMatAtt())
                        .build();
                material.setNew(true);
                materialRepository.save(material);
            }
        }

        return ItemSaveResponse.from(item);
    }
}

다음으로 controller 계층을 만듭니다.

package plato.io.exclu_demo.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import plato.io.exclu_demo.dto.ItemSaveRequest;
import plato.io.exclu_demo.dto.ItemSaveResponse;
import plato.io.exclu_demo.service.ItemService;

@RestController
@RequestMapping("/api/item")
@RequiredArgsConstructor
public class ItemController {

    private final ItemService itemService;
    
    @PostMapping
    public ResponseEntity<ItemSaveResponse> saveItem(@RequestBody ItemSaveRequest request) {
        ItemSaveResponse response = itemService.saveItem(request);
        return ResponseEntity.ok(response);
    }
}

테스트를 해봅니다.

  • 제품 저장

POST http://localhost:8080/api/item

{
    "itemType": "PRODUCT",
    "itemAtt": "COMMON",
    "prdtAtt": "PRDT"
}
  • 자재 저장

POST http://localhost:8080/api/item

{
    "itemType": "MATERIAL",
    "itemAtt": "COMMON",
    "matAtt": "MAT"
}

Read more

A2A 메모리 레이어: AI 에이전트를 위한 공유 두뇌 구축하기

A2A 메모리 레이어: AI 에이전트를 위한 공유 두뇌 구축하기

모든 것의 시작이 된 문제 우리를 계속 괴롭히던 것이 있었습니다: 왜 AI 에이전트는 대화가 끝나는 순간 모든 것을 잊어버릴까요? 한번 생각해보세요. 챗봇에게 전화보다 이메일을 선호한다고 말합니다. 다음 세션에서는요? 마치 처음 만난 것처럼 행동합니다. 한 상담원에게 주문 문제를 설명하고, 다른 상담원에게 연결되면, 갑자기 처음부터 다시 시작해야 합니다. 우리 모두 겪어본 일입니다.

By Sardor Madaminov
[시리즈 2편] 실무로 배우는 메시지 큐 - RabbitMQ

[시리즈 2편] 실무로 배우는 메시지 큐 - RabbitMQ

들어가며 [시리즈1]에서는 프로세스 내부 메시지 큐를 다뤘습니다. 이번엔 네트워크 메시지 큐인 RabbitMQ를 다룹니다. RabbitMQ 공식 문서나 기술 블로그는 많지만, 실무에서 어떻게 사용하는지에 대한 글은 의외로 적습니다. "Producer가 뭐고 Consumer가 뭔지는 알겠는데, 그래서 실제로는 어떻게 쓰는데?" 이번 글에서는 우리 MES 시스템에서 RabbitMQ를 어떻게 활용하고 있는지 실제 코드와 함께 공유합니다. 우리

By Jeonggil
[시리즈 1편] 실무로 배우는 메시지 큐 - Windows Message Loop

[시리즈 1편] 실무로 배우는 메시지 큐 - Windows Message Loop

들어가며 이 글은 "실무로 배우는 메시지 큐" 시리즈의 첫 번째 글입니다. 실무에서 발견한 문제를 해결하는 과정에서, IME 입력 문제와 해결 과정을 공유합니다. 메시지 큐는 RabbitMQ, Kafka 같은 네트워크 레벨만 있는 게 아닙니다. 우리가 매일 쓰는 Windows 애플리케이션도 메시지 큐 기반으로 동작합니다. * 시리즈1 (이 글): 프로세스 내부의 메시지 큐 - Windows

By Jeonggil
[시리즈 2편] 그림으로 풀어낸 SaaS 알림 시스템

[시리즈 2편] 그림으로 풀어낸 SaaS 알림 시스템

이 글은 1편 - 그림으로 풀어낸 SaaS 알림 시스템의 후속편입니다. 들어가며 1편에서는 설비 연속 OFF 알림 기능의 핵심 로직과 어떤식으로 해결했는지 그림으로 알아봤습니다. 이번 글에서는 실무에서 마주한 진짜 고민들을 공유합니다: * 왜 3개의 새로운 테이블이 필요했나? * 어떻게 확장 가능한 구조를 만들었나? * SMS 14원짜리 알림이 왜 무서운가? * 운영 레벨로 나가기까지 무엇을 준비했나?

By Jeonggil