여러 플랫폼에서 도형을 그리는 그래픽 라이브러리를 짠다고 하자. CircleSquare가 있고, 이것을 화면에 뿌리는 방법은 플랫폼마다 다르다 — 데스크톱에서는 OpenGL로, 웹에서는 SVG로 그린다. 가장 손에 익은 대로 짜면 상속으로 엮는다. Circle을 상속해 OpenGLCircleSVGCircle을 만들고, Square를 상속해 OpenGLSquareSVGSquare를 만든다. 도형 두 개에 플랫폼 두 개, 네 개의 클래스가 생겼다.

문제는 이 곱셈이 멈추지 않는다는 데 있다. 도형에 Triangle을 더하면 플랫폼 수만큼 — OpenGLTriangle, SVGTriangle — 클래스가 따라 붙고, 플랫폼에 Metal을 더하면 도형 수만큼 — MetalCircle, MetalSquare, MetalTriangle — 클래스가 또 붙는다. 도형 M개와 플랫폼 N개를 상속 하나의 축에 욱여넣으면 클래스가 M×N개로 불어난다. 브리지(Bridge) 패턴은 이 곱셈을 덧셈으로 되돌린다 — 두 축을 하나의 상속 계층에 묶는 대신, 구현 축을 따로 떼어내 합성이라는 다리로 잇는다.

무엇이 문제인가 — 동기

같은 증상을 리모컨으로 옮기면 더 또렷하다. BasicRemote, AdvancedRemote 같은 리모컨 종류가 있고, 이 리모컨이 조종할 기기로 TV, Radio가 있다. 상속으로 엮으면 BasicTVRemote, BasicRadioRemote, AdvancedTVRemote, AdvancedRadioRemote가 생긴다. 여기서 세 가지가 동시에 아프다.

첫째, 클래스가 곱으로 늘어난다. 리모컨 종류(추상)와 기기(구현)는 본래 서로 다른 이유로 변한다. 리모컨은 UI 기능이 늘어 변하고, 기기는 하드웨어 프로토콜이 바뀌어 변한다. 그런데 상속은 이 둘을 한 계층에 붙여 놓아, 어느 한 축이 하나 늘 때마다 다른 축 전체와 곱해진 만큼 새 클래스를 강요한다.

둘째, 변경이 두 축에 걸쳐 번진다. TV의 볼륨 조절 방식이 바뀌면 BasicTVRemoteAdvancedTVRemote를 둘 다 고쳐야 한다. 기기 하나의 변경이 그 기기를 쓰는 모든 리모컨 조합으로 퍼진다. 한 축의 사정이 다른 축에 새어 나가 있어서, 변경의 파급이 조합의 수만큼 넓다.

셋째, 런타임에 짝을 바꿀 수 없다. AdvancedTVRemote는 컴파일 시점에 “고급 리모컨 + TV”로 굳어 있다. 같은 리모컨으로 실행 중에 기기를 라디오로 갈아 끼우려면, 다른 클래스의 객체를 새로 만들어야 한다. 추상과 구현이 상속으로 융합되어 있어, 둘을 값처럼 따로 조립하지 못한다.

판단 기준: 클래스 이름이 OpenGLCircle, AdvancedTVRemote처럼 두 개의 명사가 곱해진 형태로 늘어나기 시작하면 브리지를 의심한다. 함정: 축이 둘처럼 보여도 한쪽이 앞으로 늘지 않는다면(플랫폼이 영원히 하나라면) 브리지는 오지 않을 확장에 거는 보험일 뿐이다 — 조합 폭발은 두 축이 모두 자랄 때만 실재한다.

패턴의 구조 — 누가 무엇을 하나

브리지는 하나의 상속 계층을 두 개의 독립된 계층으로 쪼갠 뒤, 둘을 참조로 잇는다. 핵심은 “무엇을 하는가(추상)“와 “그것을 실제로 어떻게 해내는가(구현)“를 서로 다른 계층으로 갈라 놓는 것이다.

Abstraction(추상). 클라이언트가 상대하는 상위 개념이다. Shape, Remote가 여기 해당한다. 자기가 할 일의 골격을 정의하되, 실제 저수준 작업은 자기가 쥔 구현 객체에게 넘긴다. 추상은 구현의 인터페이스만 알 뿐, 어느 구현이 꽂혔는지는 모른다.

RefinedAbstraction(정제된 추상). 추상을 확장한 변형들. Circle, Square 혹은 AdvancedRemote가 여기 있다. 이 축이 늘어도 구현 축은 한 줄도 건드리지 않는다 — 새 도형은 Shape를 상속한 클래스 하나면 끝난다.

Implementor(구현 인터페이스). 저수준 연산의 계약이다. DrawingAPIdrawCircle, drawLine 같은 원시 연산만 선언한다. 주의할 점은 이 인터페이스가 추상의 인터페이스와 일치할 필요가 없다는 것이다 — 추상은 “원을 그려라”라는 고수준 요청을 받고, 구현은 “이 좌표에 픽셀을 찍어라”라는 저수준 연산을 제공한다. 추상이 그 사이의 간극을 메운다.

ConcreteImplementor(구체 구현들). OpenGLAPI, SVGAPI처럼 플랫폼별로 원시 연산을 실제로 구현한 것들. 이 축이 늘어도 도형 축은 그대로다 — 새 플랫폼은 DrawingAPI를 구현한 클래스 하나면 끝난다.

둘을 잇는 것이 다리다. ShapeDrawingAPI 필드 하나를 쥐고, 자기 일을 하다가 저수준이 필요한 순간 api.drawCircle(...)로 넘긴다. 이 참조 하나가 M×N개였던 조합을 M개 도형과 N개 플랫폼, 즉 M+N개로 갈라 놓는다.

판단 기준: 추상 계층이 구현 계층의 인터페이스에만 의존하고 구체 구현을 모르면 다리가 제대로 걸린 것이다. 함정: 추상이 if (api instanceof OpenGLAPI)로 어느 구현인지 캐물으면, 다리를 놓고도 두 축을 다시 결합시킨 것이다 — 추상은 구현이 무엇인지 끝까지 몰라야 한다.

코드로 — 패턴이 자라나는 과정

상속 조합이 폭발하는 지점에서 출발해, 구현 축을 인터페이스로 뽑아 다리로 잇고, 두 축이 따로 자라는 데까지 따라간다.

Refactoring Step 상속 조합 폭발 — 도형×플랫폼이 곱으로 늘어난다
1/4
// 도형(무엇)과 플랫폼(어떻게)을 한 상속 계층에 욱여넣었다.abstract class Shape {    abstract void draw();}class OpenGLCircle extends Shape {    void draw() { /* OpenGL로 원을 그린다 */ }}class SVGCircle extends Shape {    void draw() { /* SVG로 원을 그린다 */ }}class OpenGLSquare extends Shape {    void draw() { /* OpenGL로 사각형을 그린다 */ }}class SVGSquare extends Shape {    void draw() { /* SVG로 사각형을 그린다 */ }}// 도형 2 × 플랫폼 2 = 4개. Triangle을 더하면 OpenGLTriangle·SVGTriangle이// 따라 붙고, Metal을 더하면 도형 수만큼 클래스가 또 붙는다 — M×N 폭발.// 게다가 OpenGLCircle과 SVGCircle에는 '원을 그린다'는 같은 논리가 중복된다.

첫 스텝의 네 클래스가 마지막에서 도형 둘(Circle, Square)과 플랫폼 둘(OpenGLAPI, SVGAPI)로 갈라졌다. 상속 하나로 엮여 곱해지던 두 관심사가, 참조 하나를 사이에 두고 따로 서게 된 것이 핵심이다. OpenGLCircleSVGCircle에 나뉘어 중복되던 “원을 그린다”는 논리도 Circle 한 곳으로 모였다 — 브리지는 조합 폭발을 막는 동시에 그 곱셈 안에 흩어져 있던 중복까지 걷어낸다.

판단 기준: 리팩터링 후 “새 도형 = 클래스 하나(플랫폼 무관), 새 플랫폼 = 클래스 하나(도형 무관)“가 성립하면 다리가 제대로 걸린 것이다. 함정: 다리를 놓고도 CircleOpenGLAPI를 직접 new로 생성하면, 두 축을 코드로 다시 붙인 셈이다 — 구현의 선택은 밖에서 주입되어야 축이 독립을 유지한다.

결과 — 무엇을 얻고 무엇을 내주나

브리지가 사는 값은 두 축의 독립이다. 추상과 구현이 각자의 계층에 있으니, 한쪽을 확장해도 다른 쪽은 컴파일조차 다시 하지 않는다. 클래스 수가 M×N에서 M+N으로 내려가고, 곱셈 안에 흩어져 있던 중복이 걷힌다. 추상이 구현을 참조로 쥐므로 런타임에 짝을 갈아 끼울 수 있다 — 같은 Circle 객체가 실행 중에 그리는 대상을 바꾸는 것도 가능하다. 무엇보다 클라이언트는 추상만 보고, 어느 플랫폼에 그려지는지는 구현 뒤에 완전히 숨는다.

대가는 두 가지다. 첫째, 초기 설계 부담이 크다. 전략이나 어댑터가 이미 아픈 코드에 사후 처방으로 들어가는 데 비해, 브리지는 두 축이 앞으로 어떻게 갈라질지를 미리 읽고 계층을 갈라 놓아야 한다. 무엇이 추상이고 무엇이 구현인지, 둘 사이 인터페이스를 어디에 그을지를 처음에 정확히 잡아야 하며, 이 경계를 잘못 그으면 브리지의 이득이 통째로 날아간다. 간접성도 는다 — Circle.draw()가 실제 그리기까지 한 겹을 더 거친다. 둘째, 한 축만 자라는 코드에는 순손해다. 플랫폼이 영원히 하나면, 갈라 놓은 두 계층은 이득 없는 간접성만 더한다. 조합 폭발이라는 병이 실재하지 않는 곳에 브리지를 놓으면, 없는 병에 처방부터 내린 꼴이다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 추상과 구현 두 축이 모두 앞으로 자란다면, M+N으로 억눌린 클래스 수와 축 사이에 세운 방화벽이 곱셈의 대가를 압도한다. 반대로 한 축이 고정이면, 갈라 놓은 계층은 간접성만 남기고 이득은 놀고 있다.

판단 기준: 두 축이 각각 독립적인 이유로, 각각 앞으로도 늘어난다는 확신이 설 때만 브리지를 앉힌다. 함정: “나중에 플랫폼이 늘 수도 있으니까”라는 막연한 예감으로 계층부터 가르면, 오지 않을 확장을 위해 초기 설계 부담과 간접성을 선불로 치른다 — 브리지는 확장이 예상되는 자리에 거는 것이지 모든 미래에 거는 보험이 아니다.

언제 쓰고, 언제 과용인가

브리지가 정확히 맞는 자리는 이렇다. 추상과 구현이 각각 독립적으로 늘어나고, 그 둘을 런타임에 조합하거나 갈아 끼워야 할 때. 구현의 세부(플랫폼·드라이버·API)를 클라이언트에게서 완전히 숨기고 싶을 때. JDBC가 교과서적 사례다 — 애플리케이션이 상대하는 Connection·Statement(추상)와, 각 DB 벤더가 제공하는 드라이버(구현)가 서로를 모른 채 갈라져 있고, 드라이버만 갈아 끼우면 같은 코드가 다른 DB에 붙는다. AWT의 컴포넌트와 플랫폼별 피어(peer)도 같은 구조다.

여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. [어댑터]와 브리지는 둘 다 인터페이스를 사이에 두지만 시점과 의도가 정반대다. 어댑터는 사후에 온다 — 이미 만들어진, 맞지 않는 두 인터페이스를 나중에 억지로 이어 붙이는 사후 변환이다. 브리지는 사전에 온다 — 아직 짓기 전에 두 축이 따로 자라도록 미리 갈라 세우는 사전 설계다. 어댑터는 “이미 어긋난 것을 맞춘다”, 브리지는 “처음부터 어긋나지 않게 나눈다”. 추상팩토리(Abstract Factory)는 브리지와 자주 짝을 이룬다 — 브리지가 추상에 어느 구현을 꽂을지, 그 구현 객체를 생성하는 책임을 추상 팩토리가 맡을 수 있다. 브리지는 조립된 구조를, 추상 팩토리는 그 구조에 넣을 부품의 생성을 담당한다.

판단 기준: 어긋난 두 인터페이스를 나중에 잇는 것이면 어댑터, 두 축이 각자 자라도록 처음부터 나누는 것이면 브리지다. 함정: 브리지를 “인터페이스로 구현을 감싸는 것”으로만 알면 그냥 인터페이스 분리와 구별이 안 된다 — 브리지의 정체성은 추상 축과 구현 축이 둘 다 상속 계층으로 독립해 자란다는 데 있다. 한쪽만 늘면 그건 브리지가 아니라 단순한 구현 은닉이다.

다음으로 컴포지트