무엇인가

정해진 풀이 절차가 없는 문제를 위한 구조다. 공용 저장소(칠판)에 지금까지 알아낸 것을 적어 두면, 여러 전문가 모듈이 칠판을 들여다보다가 자기가 기여할 수 있을 때 나서서 새 사실을 적는다. 답이 나올 때까지 반복한다.

파이프-필터가 “순서가 정해진” 처리라면, 블랙보드는 순서를 미리 정할 수 없을 때 쓴다. 누가 언제 쓸모 있을지는 칠판의 현재 내용에 달렸다.

구조

graph TD
    KS1[지식원: 문자 인식] -->|기여| BB[(칠판)]
    KS2[지식원: 단어 사전] -->|기여| BB
    KS3[지식원: 문법 검사] -->|기여| BB
    BB -->|현재 상태 조회| KS1
    BB -->|조회| KS2
    BB -->|조회| KS3
    CTL{제어: 누가 나설 차례인가} --> BB

C++로 보기

각 지식원은 내가 지금 기여할 수 있나를 스스로 판단한다. 제어부는 기여할 수 있는 것을 계속 돌리다가, 아무도 못 하면 멈춘다.

#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
 
// 칠판: 공용 사실 저장소다
class Blackboard {
    std::unordered_map<std::string, std::string> facts_;
 
  public:
    bool has(const std::string& k) const { return facts_.count(k) > 0; }
    std::string get(const std::string& k) const {
        auto it = facts_.find(k);
        return it == facts_.end() ? std::string{} : it->second;
    }
    void put(const std::string& k, const std::string& v) {
        facts_[k] = v;
        std::cout << "    칠판에 기록: " << k << " = " << v << "\n";
    }
};
 
class KnowledgeSource {
  public:
    virtual ~KnowledgeSource() = default;
    virtual const char* name() const = 0;
    virtual bool canContribute(const Blackboard&) const = 0;
    virtual void contribute(Blackboard&) = 0;
};
 
// 생년이 있으면 나이를 계산할 수 있다
class AgeCalculator : public KnowledgeSource {
  public:
    const char* name() const override { return "나이 계산"; }
    bool canContribute(const Blackboard& bb) const override {
        return bb.has("birthYear") && !bb.has("age");
    }
    void contribute(Blackboard& bb) override {
        int born = std::stoi(bb.get("birthYear"));
        bb.put("age", std::to_string(2026 - born));
    }
};
 
// 나이가 있어야 등급을 정한다 — 위가 먼저 돌아야 한다
class GradeAssigner : public KnowledgeSource {
  public:
    const char* name() const override { return "등급 판정"; }
    bool canContribute(const Blackboard& bb) const override {
        return bb.has("age") && !bb.has("grade");
    }
    void contribute(Blackboard& bb) override {
        int age = std::stoi(bb.get("age"));
        bb.put("grade", age >= 19 ? "성인" : "미성년");
    }
};
 
// 이름과 등급이 모두 있어야 인사말이 된다
class Greeter : public KnowledgeSource {
  public:
    const char* name() const override { return "인사말 생성"; }
    bool canContribute(const Blackboard& bb) const override {
        return bb.has("name") && bb.has("grade") && !bb.has("greeting");
    }
    void contribute(Blackboard& bb) override {
        bb.put("greeting", bb.get("name") + "님(" + bb.get("grade") + ") 환영합니다");
    }
};
 
int main() {
    Blackboard bb;
    bb.put("name", "수원");
    bb.put("birthYear", "1990");
 
    std::vector<std::unique_ptr<KnowledgeSource>> sources;
    sources.push_back(std::make_unique<Greeter>());        // 일부러 순서를 섞는다
    sources.push_back(std::make_unique<GradeAssigner>());
    sources.push_back(std::make_unique<AgeCalculator>());
 
    // 제어: 기여할 수 있는 놈이 없을 때까지 돈다
    for (int round = 1; ; ++round) {
        bool progressed = false;
        std::cout << "-- " << round << "회차\n";
        for (auto& ks : sources) {
            if (ks->canContribute(bb)) {
                std::cout << "  " << ks->name() << " 나섬\n";
                ks->contribute(bb);
                progressed = true;
            }
        }
        if (!progressed) break;
    }
 
    std::cout << "결과: " << bb.get("greeting") << "\n";
}

지식원을 일부러 뒤섞어 등록했는데도 답이 나온다. 순서를 코드가 정하지 않고 칠판의 상태가 정하기 때문이다. 새 지식원을 넣을 때 어디에 끼울지 고민할 필요가 없는 것이 이 패턴의 값이다.

대신 종료 조건이 늘 문제다. 위 코드는 “아무도 기여 못 하면 종료”인데, 지식원끼리 사실을 계속 고쳐 쓰면 영원히 돌 수 있다.

언제 쓰나

  • 정해진 알고리즘이 없는 문제. 음성 인식, 영상 이해, 계획 수립처럼 부분 단서를 모아 가는 일.
  • 여러 접근법을 섞어야 하고, 어느 것이 언제 통할지 모를 때.
  • 새로운 해법 모듈을 계속 붙여야 할 때.

대가

  • 종료와 수렴을 보장하기 어렵다. 위에서 본 그대로다.
  • 모든 것이 칠판을 거치므로 결합은 느슨하지만 추적이 어렵다. 누가 무엇을 왜 적었는지 안 남는다.
  • 제어 전략이 성능을 좌우한다. 매번 전부 물어보는 방식은 지식원이 늘면 비싸진다.
  • 칠판이 공유 상태라 병렬화하면 곧바로 동시성 문제가 된다.

실제로 만나는 곳

음성 인식 시스템(이 패턴의 원조가 Hearsay-II였다), 규칙 기반 전문가 시스템, 일부 자율주행·로보틱스의 상황 인지 모듈.