Software Architecture/Architectural Styles

Architectural Styles - 11. 마이크로커널과 서버리스

마이크로커널과 서버리스 — 코어를 최소화하는 두 형태

Eclipse IDE를 켜보자. 핵심은 작다 — 플랫폼 런타임, 번들 시스템, 워크벤치. 그 위에 수백 개의 플러그인이 얹혀 있다. JDT(Java 개발 도구), CDT(C/C++), PyDev(Python), Git 통합, 디버거, 마켓플레이스에서 다운받는 수천 개의 확장. 핵심 코어는 플러그인이 뭘 하는지 모른다 — 그저 정해진 확장점(extension point)으로 플러그인이 참여할 뿐이다. 이 구조의 이름이 마이크로커널(microkernel)이다. 반대 극단에 AWS Lambda 같은 서버리스(serverless) FaaS가 있다 — 함수 하나가 코어 없이 그 자체로 실행 단위. 두 스타일은 다른 것 같지만, "코어를 최소화하고 확장에 집중한다"는 한 축을 공유한다.

이 글이 다루는 질문: 코어는 작게, 확장은 자유롭게. 이 구조가 적합한 영역은 어디고, 어떤 비용이 따르나.

비유로 감 잡기 — 멀티탭과 가전

하나의 멀티탭을 상상하자. 멀티탭 자체(코어)는 전기를 분배하는 역할만 한다. 거기에 뭐를 꽂을지는 사용자 마음이다 — 노트북, 핸드폰 충전기, 스탠드, 선풍기. 멀티탭은 어떤 가전이 꽂혔는지, 그 가전이 뭘 하는지 모른다. 그저 정해진 규격(콘센트)으로 전기를 공급할 뿐.

마이크로커널이 이 멀티탭과 비슷하다. 코어(커널)는 최소한의 서비스(확장점 메커니즘, 라이프사이클 관리, 기본 인프라)만 제공한다. 거기에 꽂히는 플러그인(확장)이 실제 기능을 담당한다. 코어는 플러그인이 뭘 하는지 모른다 — 그저 정해진 규격(확장점)으로 통신할 뿐.

서버리스는 극단적 형태다. 코어를 "런타임"까지 최소화한다. 함수 하나가 곧 실행 단위. AWS Lambda, Azure Functions, Google Cloud Functions가 이 형태. "코어 없음"이라기보다 "코어가 벤더가 관리하는 인프라에 숨겨짐"이다.

비유의 한계 — 멀티탭 비유는 '동일 규격'을 강조하지만, 실제 플러그인은 각기 다른 확장점에 꽂힌다(IDE의 에디터 확장점 vs 프로젝트 확장점). 또한 멀티탭은 전기만 주지만, 마이크로커널은 서비스(로그, 설정, 이벤트 버스)도 제공. 비유는 '제공 서비스의 다양성'에서 무너진다.

마이크로커널 — 코어와 확장의 분리

마이크로커널 아키텍처는 두 부분으로 나뉜다.

  • 코어 시스템(core system) — 최소한의 기능. 플러그인 라이프사이클 관리, 확장점 레지스트리, 공통 서비스(로그, 설정, 이벤트 버스).
  • 플러그인 모듈(plugin modules) — 독립적인 기능 단위. 코어가 정의한 확장점에 참여해 기능을 제공. 보통 별도 배포 단위(JAR, DLL, 번들).

Richard Richards와 Neal Ford가 (2020)에서 잡는 정의는 이 구조를 명시한다 — "마이크로커널은 코어 시스템과 플러그인 모듈로 구성되며, 코어는 플러그인이 참여할 수 있는 확장점을 제공한다."

flowchart TD
    subgraph CORE["코어 시스템 (최소)"]
        LIFE[플러그인 라이프사이클]
        REG[확장점 레지스트리]
        SVC[공통 서비스<br/>로그, 설정, 이벤트 버스]
    end
    subgraph PLUGINS["플러그인 (독립)"]
        P1[에디터 플러그인]
        P2[디버거 플러그인]
        P3[Git 플러그인]
        P4[컴파일러 플러그인]
    end
    P1 -->|확장점 참여| REG
    P2 -->|확장점 참여| REG
    P3 -->|확장점 참여| REG
    P4 -->|확장점 참여| REG
    LIFE -.로드/언로드.- P1
    LIFE -.로드/언로드.- P2

핵심은 — 코어가 플러그인의 구체적인 구현을 모른다는 점이다. 코어는 '확장점'이라는 추상 인터페이스만 정의하고, 플러그인이 그걸 구현한다. 새 플러그인을 추가할 때 코어를 변경하지 않아도 된다. 이게 마이크로커널의 핵심 가치다.

마이크로커널이 자연스러운 영역

  • IDE (Eclipse, IntelliJ, VSCode) — 에디터 코어에 언어 지원·도구 통합이 플러그인.
  • 웹 브라우저 (Firefox, Chrome) — 렌더링 코어에 확장 기능이 플러그인.
  • 운영체제 커널 (Linux 모듈, 마이크로커널 연구 계열 — Mach, L4) — 커널 코어에 드라이버·파일시스템이 모듈.
  • 빌드 도구 (Maven, Gradle, Webpack) — 빌드 엔진에 컴파일·테스트·패키징이 플러그인.
  • CI/CD (Jenkins, GitLab CI) — 실행 엔진에 각 단계가 플러그인.
  • CMS (WordPress, Drupal) — 콘텐츠 관리 코어에 기능이 플러그인.

공통점 — "코어는 안정적이고, 확장은 다양하며, 사용자가 확장을 자유롭게 추가한다". 변동성이 코어가 아니라 확장에 있는 시스템에 자연스럽다.

설계 사례 — 텍스트 에디터 마이크로커널

간단한 텍스트 에디터를 코어 + 플러그인으로 설계해 본다.

코어 — 확장점과 라이프사이클

// 코어: 확장점 정의 (추상)
public interface EditorExtension {
    String name();
    void initialize(EditorContext ctx);
}

// 코어: 에디터가 발생시키는 이벤트 확장점
public interface OnDocumentSaved extends EditorExtension {
    void onSaved(Document doc);
}

// 코어: 메뉴 확장점
public interface MenuContribution extends EditorExtension {
    List<MenuItem> menuItems();
}

// 코어: 확장 레지스트리
public class ExtensionRegistry {
    private final Map<Class<?>, List<EditorExtension>> extensions = new HashMap<>();

    public void register(EditorExtension ext) {
        // 인터페이스별로 분류
        for (Class<?> iface : ext.getClass().getInterfaces()) {
            if (EditorExtension.class.isAssignableFrom(iface)) {
                extensions.computeIfAbsent(iface, k -> new ArrayList<>()).add(ext);
            }
        }
        ext.initialize(EditorContext.current());
    }

    public <T extends EditorExtension> List<T> find(Class<T> type) {
        return (List<T>) extensions.getOrDefault(type, List.of());
    }
}

// 코어: 에디터 자체 — 기능이 없음
public class Editor {
    private final ExtensionRegistry registry;
    private Document document;

    public void save() {
        document.persist();
        // 확장점 호출 — 코어는 누가 이 이벤트를 받는지 모름
        for (OnDocumentSaved ext : registry.find(OnDocumentSaved.class)) {
            ext.onSaved(document);
        }
    }
}

이 코어는 텍스트 편집 기능(저장) 외엔 아무것도 모른다. 저장 시 OnDocumentSaved 확장점을 호출한다. 그 확장점을 누가 구현했는지, 그들이 뭘 하는지 모른다.

플러그인 — 확장 기능

// 플러그인 1: 자동 백업
public class AutoBackupPlugin implements OnDocumentSaved {
    @Override
    public String name() { return "auto-backup"; }

    @Override
    public void initialize(EditorContext ctx) { /* 설정 로드 */ }

    @Override
    public void onSaved(Document doc) {
        Path backup = Path.of("backups/" + doc.name() + "." + System.currentTimeMillis());
        Files.writeString(backup, doc.content());
    }
}

// 플러그인 2: Git 커밋 자동화
public class GitAutoCommitPlugin implements OnDocumentSaved {
    @Override
    public String name() { return "git-autocommit"; }

    @Override
    public void onSaved(Document doc) {
        git.add(doc.path());
        git.commit("Auto-save: " + doc.name() + " at " + Instant.now());
    }
}

// 플러그인 3: 메뉴 확장
public class FormatMenuPlugin implements MenuContribution {
    @Override
    public List<MenuItem> menuItems() {
        return List.of(
            new MenuItem("JSON 포맷", this::formatJson),
            new MenuItem("XML 포맷", this::formatXml)
        );
    }
    // ...
}

각 플러그인은 코어의 확장점을 구현한다. 서로를 모른다. AutoBackup이 작동하든 안 하든 GitAutoCommit은 영향받지 않는다. 새 플러그인(LintPlugin, SyntaxHighlightPlugin 등)을 추가해도 코어는 변경되지 않는다 — 그냥 확장점을 구현하는 새 클래스를 등록할 뿐.

플러그인 격리 — 독립성의 핵심

// 플러그인을 별도 클래스로더로 격리 (개념적)
public class PluginLoader {
    public void load(Path pluginJar) {
        URLClassLoader loader = new URLClassLoader(new URL[]{pluginJar.toUri().toURL()});
        ServiceLoader<EditorExtension> services = ServiceLoader.load(EditorExtension.class, loader);
        for (EditorExtension ext : services) {
            registry.register(ext);
        }
    }

    public void unload(String pluginName) {
        // 플러그인의 클래스로더를 폐기
        // 해당 플러그인의 모든 확장 제거
    }
}

Java의 ServiceLoader, OSGi(Eclipse의 기반), Node.js의 npm 패키지 같은 메커니즘이 플러그인 격리를 담당한다. 플러그인이 서로 충돌하지 않게, 한 플러그인 장애가 다른 플러그인으로 번지지 않게.

마이크로커널 위반 감지

신호 의미
코어가 특정 플러그인을 직접 import 코어-플러그인 결합
새 플러그인 추가 시 코어 코드 변경 확장점이 제대로 추상화 안 됨
플러그인이 다른 플러그인을 직접 호출 플러그인 간 결합
플러그인 장애가 코어를 죽임 격리 없음
핵심 기능이 코어 대신 플러그인에 코어가 너무 빈약
코어가 너무 많은 일을 함 코어 비대 — 마이크로커널이 아님

서버리스 (FaaS) — 코어를 벤더로 옮긴 극단

서버리스 FaaS(Function as a Service)는 마이크로커널의 극단적 변형이다. 코어를 애플리케이션 코드가 아니라 벤더가 관리하는 런타임으로 옮긴다. 개발자는 함수(function)만 작성하고, 그 함수가 언제 실행될지(이벤트 트리거)와 어떻게 확장될지는 벤더가 결정한다.

AWS Lambda를 예로 든다.

// AWS Lambda 함수 — 한 파일이 곧 배포 단위
public class OrderHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    private final OrderService orderService = /* 초기화 */;

    @Override
    public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) {
        OrderRequest req = parse(input.getBody());
        OrderId id = orderService.placeOrder(req);
        return response(200, Map.of("id", id.value()));
    }
}

이 함수에는 '코어'가 없다. HTTP 서버 설정, 스레드 풀, 스케일링, 로깅 인프라 — 전부 AWS가 관리한다. 개발자는 비즈니스 로직(함수)만 쓴다.

서버리스의 아키텍처적 함의

특성 전통적 서버 서버리스 FaaS
실행 단위 장기 실행 프로세스 단기 실행 함수 (밀리초~분)
상태 프로세스 메모리에 유지 가능 함수 호출마다 초기화 (stateless)
확장 미리 용량 provisioning 이벤트마다 자동 확장
과금 서버 켜둔 시간 호출 횟수 + 실행 시간
콜드 스타트 없음 첫 호출 시 수백 ms~수 초 지연
의존성 자유롭게 설치 함수 크기·시작 시간 제약
응답 시간 일관적 콜드 스타트 시 편차 큼

이 표의 차이들이 아키텍처를 바꾼다. 특히 "stateless"와 "콜드 스타트"가 결정적이다.

Stateless가 만드는 제약

함수 호출마다 상태가 날아가므로, 상태를 외부(DB, 캐시, 세션 저장소)에 둬야 한다. 전통적 서버에선 세션을 메모리에 두는 게 자연스럽지만, 서버리스에선 함수 인스턴스가 매번 초기화되기 때문에 상태를 외부화하는 게 사실상 유일한 선택이다.

// 안티패턴 — 서버리스에선 작동 안 함
public class BadOrderHandler {
    private Map<String, Cart> sessionCarts = new HashMap<>();  // 함수 호출마다 날아감

    public void addToCart(String userId, Item item) {
        sessionCarts.computeIfAbsent(userId, k -> new Cart()).add(item);
    }
}

// 올바른 패턴 — 상태를 외부화
public class GoodOrderHandler {
    private final CartStore store;  // Redis, DynamoDB 등

    public void addToCart(String userId, Item item) {
        Cart cart = store.find(userId).orElse(new Cart(userId));
        cart.add(item);
        store.save(cart);
    }
}

이 제약이 이벤트 소싱(09편), CQRS(08편) 같은 패턴과 자연스럽게 짝하게 만든다. 서버리스 + 이벤트 소싱 + CQRS 조합이 자주 언급되는 이유.

콜드 스타트 — 서버리스의 숨은 비용

함수가 한동안 호출되지 않으면 벤더가 함수 인스턴스를 종료시킨다. 다음 호출 시 새로 인스턴스를 띄워야 해서 지연이 발생한다. 이게 콜드 스타트다. AWS Lambda의 경우 보통 수백 ms에서 수 초(Java는 특히 느림, JVM 시작 비용).

콜드 스타트 대응:

  • 언어 선택 — Python, Node.js는 시작이 빠름. Java, .NET은 느림(JVM/CLR 시작).
  • Provisioned Concurrency — AWS Lambda에서 항상 웜 상태를 유지 (추가 비용).
  • 가벼운 초기화 — heavyweight 초기화(큰 모델 로드, 원격 설정)를 함수 밖으로.
  • 용도 제한 — 콜드 스타트에 민감한 사용자 요청-응답보다, 비동기 배치·이벤트 처리에 적합.

마이크로커널 vs 서버리스 — 비교

두 스타일은 "코어 최소화"라는 축을 공유하지만, 다른 부분도 많다.

기준 마이크로커널 서버리스 FaaS
코어 위치 애플리케이션 내부 (작지만 존재) 벤더 인프라 (보이지 않음)
확장 단위 플러그인 (클래스, 번들) 함수 (한 파일)
상태 자유로움 (코어-플러그인 공유 가능) stateless 강제
확장 (scale) 직접 관리 자동 (이벤트 기반)
과금 서버 비용 호출 기반
실행 시간 제한 없음 보통 15분 제한 (Lambda)
적합 확장이 풍부한 단일 제품 (IDE, 브라우저) 이벤트 기반 단발성 작업 (웹훅, 배치)

두 스타일은 같은 축에 있지만 다른 용도다. 마이크로커널은 "확장이 풍부한 제품"에, 서버리스는 "이벤트 기반 단발성 작업"에 적합하다.

두 스타일의 비용

마이크로커널 비용

  • 확장점 설계 어려움 — 처음부터 모든 확장 요구를 예측할 수 없다. 확장점을 잘못 잡으면 플러그인이 우회하게 됨.
  • 플러그인 간 충돌 — 같은 확장점에 참여하는 플러그인이 충돌. 우선순위·의존성 관리 필요.
  • 버전 관리 복잡 — 코어가 업그레이드될 때 플러그인 호환성. 플러그인 생태계 전체의 안정성 부담.
  • 테스트 어려움 — 모든 플러그인 조합을 테스트하기 어려움.

서버리스 비용

  • 벤더 종속 (vendor lock-in) — 함수 API, 트리거, 과금 모델이 벤더마다 다름. 이전 비용 큼.
  • 관측성 제약 — 분산 추적·로깅이 벤더 도구에 의존.
  • 로컬 개발 어려움 — 벤더 환경을 로컬에서 완전히 재현 불가.
  • 콜드 스타트 — 앞서 본 대로.
  • 장기 실행 부적합 — 15분(또는 그 이상) 제한. 긴 작업엔 부적합.

다음으로 — 스타일 비교

지금까지 열두 편에 걸쳐 주요 아키텍처 스타일을 다뤘다 — 모놀리스·계층형·헥사고날·클린·마이크로서비스·마이크로서비스 함정·이벤트 기반·CQRS·이벤트 소싱·파이프라인·마이크로커널·서버리스. 마지막 편(12)에선 같은 요구사항을 두고 이 스타일들의 trade-off를 한 표로 비교한다. "정답"이 아니라 "주어진 품질 속성에서 어느 쪽이 맞나"를 묻는 게 아키텍처 작업이기 때문이다.

마이크로커널과 서버리스의 공통 통찰은 "코어는 작게, 변동성은 확장으로"다. 변동성이 코어에 있다면(비즈니스 규칙이 자주 바뀐다면) 모놀리스가 낫고, 변동성이 확장에 있다면(기능이 자주 추가되지만 코어는 안정적이다면) 마이크로커널이나 서버리스가 자연스럽다.


참고

  • Richards, Ford — (O'Reilly, 2020), Ch.12 (마이크로커널)
  • Fowler — "Serverless Architectures" (martinfowler.com/articles/serverless.html, 2016) — 접근 2026-07-20
  • Eclipse Foundation — "Eclipse Platform Architecture" (플러그인 모델 공식 문서) — 접근 2026-07-20
  • AWS — "AWS Lambda Developer Guide" (콜드 스타트, provisioned concurrency 공식 문서) — 접근 2026-07-20
  • Shaw, Garlan — (Prentice Hall, 1996), 아키텍처 스타일 장