트렌드브리프
플라이웨이트 패턴, 객체 공유로 메모리 절약하기

플라이웨이트 패턴, 객체 공유로 메모리 절약하기

· 게시 · 약 5분 분량

플라이웨이트 패턴은 객체를 내재적 상태와 외재적 상태로 나눠 공유 가능한 부분을 팩토리로 재사용하는 구조 패턴이다. 자바 Integer 캐시(-128~127)와 문자열 풀이 대표 사례이며, 같은 값 객체가 대량으로 필요할 때 메모리를 아낀다.

목차
  1. 왜 굳이 객체를 공유하나
  2. 내재적 상태와 외재적 상태는 어떻게 나누나
  3. 자바 Integer 캐시로 보는 실제 동작
  4. 팩토리로 공유 인스턴스를 관리하기
  5. 언제 쓰고 언제 피해야 하나

플라이웨이트 패턴은 같은 데이터를 담은 객체가 수만, 수백만 개씩 필요할 때 공통 부분을 하나만 만들어 여러 곳에서 나눠 쓰는 구조 패턴이다. 객체의 상태를 어디서나 똑같은 부분과 쓰이는 자리마다 달라지는 부분으로 갈라, 똑같은 부분만 재사용하는 것이 핵심이다. 에리히 감마를 비롯한 네 저자가 쓴 『디자인 패턴』(1994, 흔히 GoF라 부른다)은 이 패턴을 공유로 수많은 작은 객체를 효율적으로 다루는 방법으로 정의한다. 이 글은 객체지향 언어에서 같은 값 객체가 대량으로 생기는 상황을 기준으로 개념과 판단 기준을 다룬다. 특정 프레임워크의 캐시 설정이나 가비지 컬렉터 튜닝은 범위 밖이다.

왜 굳이 객체를 공유하나

중복 데이터를 담은 객체가 너무 많아 메모리를 감당하기 어려울 때 쓴다. 리팩토링구루의 자바 예제는 100만 그루가 서 있는 숲을 그린다. 나무마다 이름, 색, 텍스처, 좌표를 전부 들고 있는 객체를 하나씩 만들면 RAM 사용량이 크게 불어난다. 문제는 이름, 색, 텍스처가 나무 종류마다 같다는 점이다. 소나무 50만 그루가 똑같은 텍스처 이미지를 각자 복사해서 들고 있는 셈이다.

플라이웨이트는 이 중복을 걷어낸다. 종류별로 공통인 이름, 색, 텍스처를 TreeType 객체 하나에 담아 공유하고, 나무마다 다른 좌표만 개별 객체가 들고서 TreeType을 가리키게 한다. 그러면 소나무 50만 그루가 TreeType 하나를 함께 참조하므로 텍스처는 메모리에 딱 한 벌만 남는다. 객체 수 자체를 줄이는 게 아니라, 객체가 짊어진 데이터의 무게를 줄이는 것이다. 리팩토링구루의 플라이웨이트 문서가 이 숲 예제를 자세히 다룬다.

내재적 상태와 외재적 상태는 어떻게 나누나

여러 객체가 공유해도 값이 변하지 않는 부분이 내재적 상태이고, 객체마다 달라 공유할 수 없는 부분이 외재적 상태다. 앞의 숲에서 텍스처는 내재적 상태, 좌표는 외재적 상태다.

나누는 실전 기준은 한 문장으로 끝난다. "이 값이 서로 다른 두 객체에서 같을 수 있는가?" 같을 수 있으면 내재적 상태로 빼서 공유하고, 반드시 달라야 하면 외재적 상태로 남겨 둔다. 내재적 상태는 플라이웨이트 객체 안에 저장되어 공유되고, 외재적 상태는 클라이언트가 들고 있다가 플라이웨이트의 메서드를 호출할 때 인자로 넘긴다.

구분내재적 상태외재적 상태
저장 위치플라이웨이트 내부클라이언트가 보관, 호출 시 전달
공유 여부여러 객체가 공유객체마다 고유
숲 예시이름, 색, 텍스처x, y 좌표
가변성불변상황마다 다름

이 분리가 어설프면 패턴 전체가 무너진다. 외재적 상태를 실수로 플라이웨이트 안에 저장하면, 공유하려던 객체가 특정 좌표에 묶여 버려 더는 공유할 수 없다.

왼쪽 공유 원본 나무 세 그루에 오른쪽 숲의 수많은 나무가 참조선으로 연결된 선화 일러스트
수백 그루의 나무가 몇 개의 공유 원본을 참조한다 — 같은 상태를 나눠 써 메모리를 아낀다.

자바 Integer 캐시로 보는 실제 동작

직접 구현하기 전에, 자바 표준 라이브러리 안에 이미 들어 있는 사례를 보면 감이 빠르다. Integer.valueOf가 그것이다. OpenJDK의 Integer.valueOf 구현은 기본적으로 -128부터 127까지의 값을 미리 만들어 캐시해 두고, 이 범위 안이면 새 객체 대신 캐시된 같은 객체를 돌려준다.

Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b); // true, 캐시된 같은 객체

Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
System.out.println(c == d); // false, 캐시 범위 밖이라 새 객체

그래서 127은 두 참조가 같은 객체(==가 true)지만, 128은 캐시 범위를 벗어나 매번 새 객체가 만들어진다. 여기서 자주 쓰는 작은 정수 값 자체가 공유 대상인 내재적 상태이고, -128부터 127까지를 그 대상으로 삼은 것이다. OpenJDK 기준 캐시 상한은 -XX:AutoBoxCacheMax 옵션으로 늘릴 수 있다.

이 동작은 실무에서 함정이 되기도 한다. 값 비교에 ==를 쓰면 127까지는 우연히 맞고 128부터 틀리는 버그가 나온다. 래퍼 타입의 값 비교를 항상 equals로 해야 하는 이유가 여기에 있다.

팩토리로 공유 인스턴스를 관리하기

플라이웨이트를 직접 만들 때 핵심은 팩토리다. 요청받은 내재적 상태에 해당하는 객체가 이미 있으면 그것을 돌려주고, 없을 때만 새로 만들어 보관한다. 클라이언트가 new로 객체를 직접 만들지 못하게 막고 반드시 팩토리를 거치게 해야 공유가 보장된다.

class ShapeFactory {
 private static final Map<String, Circle> cache = new HashMap<>();

 static Circle get(String color) {
  return cache.computeIfAbsent(color, Circle::new);
 }
}

팩토리 내부는 대개 해시 테이블에 키로 객체를 저장하는 구조라, 같은 색을 다시 요청하면 저장해 둔 인스턴스를 그대로 찾아 돌려준다. computeIfAbsent는 키가 없을 때만 뒤의 생성 함수를 호출하므로, 이 조회 후 생성 흐름을 한 줄로 표현한다.

자바 문자열 풀도 같은 원리다. 소스에 적은 문자열 리터럴은 풀에 한 벌만 저장되고, String.intern을 호출하면 같은 내용의 문자열이 풀에 있는 하나의 참조로 모인다. 팩토리 역할을 JVM이 대신하는 셈이다.

언제 쓰고 언제 피해야 하나

같은 값을 가진 객체가 대량으로 만들어지고, 그 객체가 지닌 데이터의 상당 부분이 중복일 때만 쓴다. 두 조건이 함께 성립하지 않으면 이득이 없다. 도입을 저울질할 때 확인할 신호는 이렇다.

반대로 피해야 할 상황도 분명하다.

결국 플라이웨이트는 메모리와 CPU를 맞바꾸는 거래다. 중복이 압도적일 때만 그 거래가 남는다. 대량으로 같은 객체가 쏟아지는 문제가 없다면, 넣지 않는 편이 코드에 더 낫다.

더 많은 글 보기 RSS 구독