Flyweight 패턴: 내부 상태를 공유해 메모리를 아끼는 법

Flyweight 패턴이 내부 상태와 외부 상태를 나눠 대량의 유사 객체를 캐시로 공유하고 메모리·GC 부하를 줄이는 원리를 정리한다.

2026-08-13 · 최초 발행 2025-10-14

같은 텍스처를 백만 번 만들 필요는 없다

숲 장면을 렌더링하는데 나무가 백만 그루라고 해서 나무 객체를 백만 개 새로 만들 필요는 없다. 이름·색상·텍스처 경로 같은 정보는 나무 종류별로 몇 가지밖에 없고, 좌표만 그루마다 다르기 때문이다. Flyweight 패턴은 이렇게 동일하거나 유사한 내부 상태를 가진 객체를 공유해 메모리 사용량을 줄이는 구조적 패턴이다. 객체의 상태를 내부 상태(Intrinsic)와 외부 상태(Extrinsic)로 나눠 내부 상태만 공유하는 것이 핵심 메커니즘이다.

동작 방식은 이렇다. 팩토리(Factory)가 내부 상태를 키로 캐시를 유지하고, 요청이 들어오면 캐시를 조회한 뒤 공유 인스턴스를 반환한다. 외부 상태는 호출 시점에 메서드 파라미터나 별도 컨텍스트 객체로 주입된다. 공유되는 객체는 불변(Immutable)으로 설계하는 것이 원칙인데, 멀티스레드 환경에서 동시 접근 안전성과 캐시 재사용성을 함께 보장하기 위해서다.

무엇을 공유하고 무엇을 매번 전달하는가

**내부 상태(Intrinsic)**는 폰트 메타데이터, 텍스처 경로, 스타일 템플릿처럼 객체 간에 공통이고 변하지 않는 정보로, 메모리 상에서 하나의 인스턴스로 공유된다. **외부 상태(Extrinsic)**는 좌표, 현재 색상, 사용자 입력처럼 호출마다 달라지는 정보로, 메서드 인자로 전달하거나 별도 컨텍스트 객체로 관리한다.

Flyweight 인터페이스는 외부 상태를 받아 동작하는 API를 정의해 외부 상태 주입만으로 부작용 없이 동작하도록 하고, Concrete Flyweight 구현체는 내부 상태만 보유하는 불변 클래스로 만든다. 직렬화가 가능하게 설계해두면 전송이나 디스크 캐시와도 호환된다.

팩토리는 내부 상태 키를 기준으로 인스턴스를 생성·조회하는 책임을 지며, 캐시는 기본적으로 ConcurrentHashMap을 쓰는 편이 무난하다. 강한 참조는 성능이 우선일 때, Soft/Weak 참조는 메모리 압박 시 회수가 필요할 때 선택하는데, 생성 비용과 히트율, 메모리 한계를 함께 저울질해야 한다. 동시성은 computeIfAbsent로 원자적 생성을 보장하는 편이 더블 체크 락킹보다 낫고, equals/hashCode는 참조 동일성(==)이 아니라 내부 상태 기준으로 정의하는 편이 안전하다.

이 패턴은 동일한 내부 상태 조합 수가 제한적이고 외부 상태를 호출 시점에 전달할 수 있을 때 적합하며, 객체 생성·메모리 비용이 큰 타입일수록 효과가 커진다. 반대로 내부 상태가 자주 바뀌어야 하거나 캐시 키의 카디널리티가 매우 높다면 적합성이 떨어지고 캐시 오염이나 메모리 누수 위험도 함께 커진다.

대량의 유사 객체를 다루는 영역들

텍스트 렌더링 엔진에서는 글리프(폰트+코드포인트) 메타데이터를 공유하고 위치·색상·크기만 외부 상태로 전달하면, 수십만 글자짜리 문서도 실제로는 수백 종 수준의 글리프 객체로 렌더링할 수 있다. 게임 오브젝트 렌더링에서는 나무·바위·NPC 타입 정보를 공유하고 좌표·방향·애니메이션 프레임만 외부 상태로 주입하며, 파티클 시스템에서는 텍스처·블렌딩 모드를 공유해 GPU/CPU 메모리를 아낀다. UI에서는 아이콘 비트맵이나 SVG 파스 결과를 공유하고 위상·색상 변형만 호출 시 전달하며, 테마 CSS 토큰을 공유하면 스타일 객체가 폭증하는 것을 막을 수 있다. 국가 코드·통화·조직 같은 변경 주기가 낮은 참조 데이터도 DTO마다 값을 복제하는 대신 레퍼런스로 공유하면 메모리 풋프린트를 줄일 수 있다.

캐시 조회부터 렌더링까지의 흐름

히트미스무효 키/포맷 오류Soft/Weak 참조강한 참조입력: 내부 상태 키, 외부 상태컨텍스트캐시 조회공유 인스턴스 반환인스턴스 생성 캐시에 저장외부 상태 주입 호출:draw(x,y,color)...출력: 렌더링/계산 결과에러 처리: 기본 타입 대체 또는예외메모리 압박정책GC 회수 허용용량 제한/만기/통계 모니터링

숫자로 보는 절감 효과

객체 크기 128바이트짜리 인스턴스를 100만 개(N=1,000,000) 만들어야 하고 내부 상태 유형이 100종(K=100)이라고 가정해보자. 순진하게 구현하면 128MB 수준의 메모리가 필요하다. Flyweight로 구현하면 내부 상태 캐시는 128B×K, 즉 약 12.8KB면 되고 여기에 외부 상태(예: 좌표당 16바이트×N)로 16MB가 더해져 총 메모리는 약 7.5배 절감된다. 객체 수와 생성률이 줄어드는 만큼 Minor/Full GC 빈도도 함께 낮아진다. 캐시 히트율이 오르면 생성 비용도 그만큼 줄어들지만, 반대로 캐시 조회 오버헤드와 간접 호출 비용은 소폭 늘어난다는 점은 감안해야 한다.

관점 순진 구현 Flyweight 패턴
메모리 사용량 객체수×객체크기 유형수×내부상태 + 개수×외부상태
확장성 대량 객체에서 급증 내부 상태 공유로 완만
일관성 객체별 설정 편차 가능 내부 상태 중앙집중 관리 용이
안정성 GC 압박·STW 위험 증가 GC 부하 완화 가능
운영 편의 단순, 추적 용이 캐시 정책·모니터링 필요

나무 숲 예제로 보는 캐시 동작

JDK 17, 단일 JVM 기준이며 멀티스레드 안전이 필요하면 ConcurrentHashMap을 사용한다.

// Flyweight 인터페이스: 외부 상태를 받아 동작
public interface TreeType {
    void render(int x, int y, String season); // x,y,season은 Extrinsic
    String key(); // 내부 상태 키
}

// Concrete Flyweight: 내부 상태(이름, 색상, 텍스처) 불변 보유
public final class BasicTreeType implements TreeType {
    private final String name;
    private final String color;
    private final String texturePath;

    public BasicTreeType(String name, String color, String texturePath) {
        this.name = name;
        this.color = color;
        this.texturePath = texturePath;
    }

    @Override
    public void render(int x, int y, String season) {
        // 렌더링 대신 출력 대체
        System.out.printf("Draw %s at (%d,%d) with color=%s, tex=%s, season=%s%n",
                name, x, y, color, texturePath, season);
    }

    @Override
    public String key() {
        return name + "|" + color + "|" + texturePath;
    }
}

// Factory: 내부 상태 키 기반 캐시
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class TreeTypeFactory {
    private static final ConcurrentMap<String, TreeType> CACHE = new ConcurrentHashMap<>();

    public static TreeType get(String name, String color, String texturePath) {
        String key = name + "|" + color + "|" + texturePath;
        return CACHE.computeIfAbsent(key, k -> new BasicTreeType(name, color, texturePath));
    }

    // 관찰/운영용
    public static int cacheSize() { return CACHE.size(); }
}

// Client: Extrinsic 상태는 호출 시 전달
public class Forest {
    public static void main(String[] args) {
        // 같은 내부 상태를 공유
        TreeType oak = TreeTypeFactory.get("Oak", "Green", "/tex/oak.png");
        TreeType pine = TreeTypeFactory.get("Pine", "DarkGreen", "/tex/pine.png");

        // 수십만 좌표에 대해 외부 상태만 다르게 호출
        for (int i = 0; i < 3; i++) {
            oak.render(10 + i, 20 + i, "SPRING");
            pine.render(100 + i, 200 + i, "WINTER");
        }
        System.out.println("Cache size = " + TreeTypeFactory.cacheSize()); // 2
    }
}

computeIfAbsent를 쓰면 경쟁 조건 없이 스레드 안전하게 캐시를 채울 수 있다. 메모리 압박에 대응해야 한다면 캐시 값을 SoftReference로 감싸고 null이 된 참조를 정리하는 루틴을 추가하는 편이 좋고, 캐시 히트/미스 카운터와 사이즈 메트릭을 노출해두면 캐시가 예상보다 커질 때 경고 임계치를 걸 수 있다.

공유 객체를 안전하게 운영하려면

공유되는 상태는 불변으로 만들어 변조를 원천 차단하는 것이 기본이다. 다만 내부 상태를 바꿔야 하는 요구가 생기면 재생성 비용이 늘어난다는 점은 감수해야 한다. 캐시 정책은 강한 참조(일관된 성능, 대신 메모리 상한을 직접 관리해야 함)와 Soft/Weak 참조(메모리는 자동으로 회수되지만 예기치 않은 캐시 미스로 지연이 늘 수 있음) 중에서 고른다. 키는 직렬화 가능한 형태로 압축해서 쓰고, 키 카디널리티가 잘못 늘어나 캐시가 폭증하지 않도록 설계해야 한다. 팩토리는 단일 진입점으로 두되, 히트율이 높은 환경에서는 락 경합을 줄이기 위해 맵을 분할하거나 읽기에 최적화된 경로를 따로 두는 것도 검토할 만하다.

대량의 유사 객체를 다루는 시나리오에서는 이런 설계로 메모리 절감, GC 부하 완화, 일관성 개선 효과를 얻을 수 있다. 불변 설계와 적절한 캐시 정책, 관측성 확보를 전제로 점진적으로 도입하는 편이 안전하다.

디자인 패턴구조 패턴Flyweight 패턴메모리 최적화객체지향 설계