-
스프링 DI 기반 전략 패턴 : 레지스트리 패턴(Registry Pattern)백엔드 : 서버공부 2025. 12. 21. 01:12728x90
들어가며
입사하고 처음으로 도메인을 처음부터 설계해서 구성하고 구현하는 일을 맡게되었다!!

맡게된 도메인은 사실 간단한데, 사용자의 파일 다운로드 요청을 처리하고, 요청 자체를 기록하는 리포트 도메인이었다.
아마 모든 회사에서, 대부분의 프로젝트에서 해당 도메인이 존재할 것이라고 생각이 된다. ( 요청 로그 같은 것 )
이와 관련에서 이번에 파일을 생성하고 클라이언트에게 내려주는 유틸을 만드는 기능을 개발하게 되었다.
대략적인 배경을 설명하자면 리포트 도메인이 사용자의 파일 생성 다운로드 요청에대한 기록을 관리하게 되니
해당 도메인에서 파일 생성에 대한 작업을 담당하자는 것이었다.
정리하자면 현재 각자 도메인에 흩어저있던 파일 생성에대한 책임을 리포트 도메인 한곳에서 관리하자는 것으로 볼 수 있다.
이렇게 되면 추후 MSA구조에서 파일관련된 작업이 부하가 심하다면 별도의 서버 인스턴스로 분리해서 관리하기도 용이할 것이다.
요구사항 분석
서버에서 생성해야하는 파일 유형은 다양했다.
엑셀을 시작으로 CSV 같은 다양한 포맷을 점진적으로 지원해야했다.
클라이언트가 요청파라미터에 파일 유형을 함께 보내면 그 요청에따라 적절한 파일을 만들어줘야했다.
나는 이전에 글에서 만들었던 ExcelGenerator와 같은 파일 생성 클래스를 확장자 별로 따로 구현하는 것으로 초기 아키텍처를 설계했다.
그리고 각 요청에 대한 생성 클래스 매핑은 아래와 같이 분기문을 통해 처리하려했다.
if (format == ReportFileFormat.EXCEL) { // 엑셀 생성 } else if (format == ReportFileFormat.CSV) { // CSV 생성 }하지만 곧바로 PDF 유형이 추가되었고, 그에 따라 또 하나의 분기문을 더 추가해야 했다.
자바 개발을 하다 보면 이런 순간에 자연스럽게 위화감을 느끼게 된다.
같은 성격의 작업을 수행하는 로직에 유형이 하나 늘어났다는 이유만으로 비즈니스 로직을 직접 수정해야 하는 구조는,
자바가 가진 장점은 물론이고 더 나아가 스프링이 지향하는 철학과도 잘 어울리지 않기 때문이다.비즈니스 로직은 “엑셀인지, CSV인지, PDF인지”와 같은 구체적인 구현이 아니라, 파일을 생성한다는 역할 자체에 의존해야 한다.
새로운 파일 포맷이 추가되는 일은 기존 로직을 수정하는 일이 되어서는 안된다는 것이다.
전략 패턴 사용 결정
내가 이 기능을 만들면서 지향한 점이있다면 추상화와 캡슐화 그리고 OCP 원칙의 달성이었다.
구체적으로 말하자면 하나의 컨트롤러가 지원해야하는 차일확장자 유형이 증가한다고해도 해당 유틸을 사용하는 컨트롤러측의 코드 수정은 전혀 없도록 구현하는 것이 목표였다.
이를 해결해주는 해법으로 가장 적절하다고 판단된것은 전략 패턴중 하나인 레지스트리 패턴이었다.
전략 패턴이 다루는 문제
전략 패턴은 다음 조건을 만족하는 상황을 해결하기 위한 패턴이다.
- 동일한 목적을 가진 여러 알고리즘이 존재하고
- 구현 방식은 서로 다르며
- 어떤 알고리즘을 사용할지는 런타임에 결정된다
파일 생성 로직은 이 조건에 정확히 부합한다. 엑셀, CSV, PDF는 모두 “파일 생성”이라는 동일한 목적을 가지지만, 구현은 서로 다르다.
따라서 파일 생성 로직을 하나의 인터페이스로 추상화하고, 포맷별 구현을 전략으로 분리했다.
public interface FileGenerator { FileData generate(String fileName, List<?> rows); String encodeFileName(String fileName); MediaType mediaType(); ReportFileFormat format(); }enum을 전략 선택의 기준으로 사용하는 이유
전략을 분리한 이후 남는 문제는 “어떤 전략을 선택할 것인가”다.
이때 문자열 비교나 조건문을 다시 사용하면 구조적인 문제가 반복된다.
알고리즘 선택의 키는 파일 유형이라고 생각하고 이를 전략 선택의 기준으로 사용해야겠다고 생각했다.
EXCEL, CSV, PDF아래와 같이 파일 유형을 enum으로 표현했다.
public enum ReportFileFormat { EXCEL(".xlsx"), CSV(".csv"), PDF(".pdf"); }그리고 각 파일 생성자가 자신이 담당하는 포맷을 직접 반환하도록 했다. CSV 파일 생성기의 예시이다.
@Component public class CsvFileGenerator implements FileGenerator { @Override public FileData generate(String fileName, List<?> rows) { // 파일 생성 알고리즘 } @Override public String encodeFileName(String fileName) { return URLEncoder .encode(fileName + format().getExtension(), UTF_8) .replaceAll("\\+", "%20"); } @Override public MediaType mediaType() { return MediaType.TEXT_PLAIN; } @Override public ReportFileFormat format() { return ReportFileFormat.CSV; } }이 방식의 핵심은 전략 선택 문제가 조건 분기가 아니라 “키 기반 조회” 문제로 변환된다는 점이다.
스프링 DI에서 전략 패턴이 동작하는 방식

레지스트리 전략 패턴의 핵심인 팩토리 메서드는 아래와같이 구성하였다.
@Component public class FileGeneratorFactory { private final Map<ReportFileFormat, FileGenerator> generators; public FileGeneratorFactory(List<FileGenerator> generatorList) { this.generators = generatorList.stream() .collect(Collectors.toMap( FileGenerator::format, Function.identity() )); } public FileGenerator getFileGenerator(ReportFileFormat format) { return Optional.ofNullable(generators.get(format)) .orElseThrow(() -> new CustomException(ResponseCode.FAILED_GENERATE_FILE)); } }스프링은 특정 인터페이스를 구현한 모든 빈을 자동으로 수집할 수 있다. FileGenerator 인터페이스를 구현한 모든 전략 클래스는 @Component 로 등록되어 있다.
public FileGeneratorFactory(List<FileGenerator> generatorList)이 생성자 시점에 스프링은 다음 작업을 수행한다.
- FileGenerator 타입을 구현한 모든 빈을 검색
- 해당 빈들을 List로 구성
- 생성자를 통해 주입
즉, “전략 구현체를 수집하는 책임”은 스프링 DI가 담당한다.
여기서 의문이 생길 수 도있다.
@RequiredArgsConstructor 를 붙이면 알아서 생성자 주입될텐데 왜 굳이 일부러 생성자를 만들었지?
바로 이 지점이 레지스트리 패턴의 핵심이다.
List는 자동 주입되지만, 아래와 같은 형태는 자동 주입되지 않는다.
Map<ReportFileFormat, FileGenerator>그 이유는 스프링이 Map을 구성할 때 사용하는 key가 빈 메타데이터(주로 bean name)이기 때문이다.
그에 반해서 ReportFileFormat.EXCEL은 빈 메타데이터가 아니라 런타임 로직의 결과다.
스프링 컨테이너는 이 관계를 사전에 알 수 없다.
따라서 enum을 key로 사용하는 전략 레지스트리는 아래와 같이 애플리케이션 코드에서 직접 구성해야 한다.
public FileGeneratorFactory(List<FileGenerator> generatorList) { this.generators = generatorList.stream() .collect(Collectors.toMap( FileGenerator::format, Function.identity() )); }이 코드에서 책임은 명확히 분리된다.
- 전략 구현체의 수집은 스프링 DI가 담당
- 전략 간의 관계 정의(format → generator)는 애플리케이션 설계가 담당
이렇게 구성된 Map은 “전략 레지스트리” 역할을 하게된다.
전략 선택의 실제 사용 지점과 런타임 동작 과정
이 구조의 핵심은 컨트롤러에서 드러난다.
FileGenerator generator = generatorFactory.getFileGenerator(format);이 코드는 단순히 “팩토리에서 객체 하나를 꺼낸다” 정도로 보일 수 있다.
하지만 런타임에서는 이 한 줄이 꽤 많은 일을 수행한다.이 동작을 이해하려면, 요청이 들어오기 이전과 이후를 나눠서 봐야 한다.
애플리케이션 시작 시점에 이미 벌어진 일
애플리케이션이 시작될 때 스프링 컨테이너는 다음 클래스들을 빈으로 생성한다.
@Component public class ExcelFileGenerator implements FileGenerator { ... } @Component public class CsvFileGenerator implements FileGenerator { ... }이 두 클래스는 모두
FileGenerator인터페이스를 구현하고 있기 때문에,
스프링은 이들을FileGenerator타입의 빈으로 관리한다.그리고
FileGeneratorFactory가 생성될 때, 생성자에서 다음과 같은 주입이 일어난다.public FileGeneratorFactory(List<FileGenerator> generatorList)이 시점에서
generatorList안에는 다음 객체들이 이미 들어 있다.- ExcelFileGenerator 인스턴스
- CsvFileGenerator 인스턴스
즉, 전략 구현체들은 요청이 오기 전부터 이미 메모리에 존재하고 있다.
이후 생성자 내부에서 이 List는 Map으로 변환된다.
this.generators = generatorList.stream() .collect(Collectors.toMap( FileGenerator::format, Function.identity() ));이 코드가 실행되면 내부적으로 다음과 같은 Map이 만들어진다.
{ ReportFileFormat.EXCEL -> ExcelFileGenerator 객체, ReportFileFormat.CSV -> CsvFileGenerator 객체 }이 Map이 바로 전략 레지스트리다.
중요한 점은 이 작업이 애플리케이션 시작 시점에 한 번만 수행된다는 것이다.CSV 요청이 들어왔을 때 실제로 벌어지는 일
이제 클라이언트가 다음과 같은 요청을 보낸다고 가정해보자.
GET /admin/report/agendas?format=CSVSpring MVC는 요청 파라미터를 파싱해서 아래처럼 변환한다.
ReportFileFormat format = ReportFileFormat.CSV;그리고 컨트롤러에서 이 코드가 실행된다.
FileGenerator generator = generatorFactory.getFileGenerator(format);이때 벌어지는 일은 다음과 같다.
format값은ReportFileFormat.CSVgeneratorFactory내부의 Map에서CSV키로 조회- value로 매핑된
CsvFileGenerator객체 반환 - 반환 타입은
FileGenerator인터페이스
즉, 실제 런타임 객체는
CsvFileGenerator지만,
컨트롤러는 그 사실을 알 필요가 없다.이후 코드에서 호출되는 generator.generate(...) 는 동적 디스패치에 의해
CsvFileGenerator.generate()가 실행된다.EXCEL 요청이 들어왔을 때의 흐름
EXCEL 요청도 완전히 동일한 구조로 동작한다.
GET /admin/report/agendas?format=EXCEL이 경우
format값은ReportFileFormat.EXCEL이 된다.FileGenerator generator = generatorFactory.getFileGenerator(format);위 줄에서 내부 Map은 다음 값을 반환한다.
ReportFileFormat.EXCEL -> ExcelFileGenerator 객체결과적으로
generator변수에는ExcelFileGenerator인스턴스가 담긴다.이후 코드에서 호출되는 generator.generate(...) 는
ExcelFileGenerator.generate()를 호출하게 된다.컨트롤러 입장에서는 CSV 요청이든, EXCEL 요청이든 완전히 동일한 코드를 실행하고 있다.
차이는 오직 런타임에 선택된 전략 객체뿐이다.여기서 중요한 점은 디음 세 가지다.
첫째
전략 객체는 요청 시점에 생성되지 않는다.
이미 스프링 컨테이너에 의해 생성된 싱글톤 빈을 재사용한다.
둘째
전략 선택은 조건문이 아니라 Map 조회로 이루어진다.
포맷 enum은 단순한 분기 조건이 아니라 전략을 가리키는 키 역할을 한다.
셋째
컨트롤러는 구체 클래스에 대해 아무것도 모른다.
ExcelFileGenerator,CsvFileGenerator라는 이름조차 알지 못하고오직
FileGenerator라는 추상 타입에만 의존한다.마무리
한동안 동시성에 꽂혔었는데 요즘은 자바개발의 본질이라고 할 수 있는 추상화 캡슐화에 집중하고있다.
학부시절에는 특정 문제를 해결하는 것에 집중했다면,
현업으로 오니 자연스럽게 오래가는 코드, 유지보수가 쉬운 코드를 구현하는 것에 자연스래 관심사가 옮겨가는 것 같다.
추상화를 개발의 추구미(?)로 하다보니 자연스래 스프링에대한 이해도 깊어지는 것 같고
이전과는 다른 방향으로 조금씩 성장하고 있다는 느낌이 든다.'백엔드 : 서버공부' 카테고리의 다른 글
대용량 Excel 다운로드 설계: OOM피하기 (0) 2025.12.14 헥사고날에서 QueryDSL 지키기 (0) 2025.11.30 "나야 503" : 추석 코레일 대란 (0) 2025.09.30 Caffeine Cache 로 동시성 제어하는거 알려 드릴까요? (0) 2025.06.26 그 날 AWS는 떠올렸다 : 람다 (0) 2024.11.24