Spring 기반 프로젝트에서 컨트롤러의 요청 파라미터를 @RequestBody Map<String, Object>로 받는 코드를 종종 봅니다. 빠른 개발이 필요할 때는 편리하지만, 시간이 지나면서 유지보수의 발목을 잡는 대표적인 안티패턴 중 하나입니다.
컨트롤러 파라미터로 Map<String, Object>를 지양해야 하는 이유와, 대안으로 DTO를 사용할 때 얻을 수 있는 이점을 정리합니다.
Map을 쓰면 왜 편할까?
먼저 왜 이런 코드를 작성하게 되는지 짚어볼 필요가 있습니다.
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody Map<String, Object> params) {
return userService.create(params);
}
DTO 클래스를 따로 만들 필요가 없고, 프론트엔드 요청 스펙이 바뀌어도 서버 코드에는 컴파일 에러가 나지 않으니 수정할 부분이 적습니다. 일단 돌아가는 코드를 만들기에는 확실히 빠릅니다.
특히 MVP 단계에서 빠른 개발이 필요하고 프론트 요구사항이 수시로 바뀌는 상황이라면, Map을 선택하는 게 합리적으로 보이기도 합니다. 다만 돌이켜보면 프론트 스펙이 자주 바뀐다는 건 곧 서버 설계가 흔들리고 있다는 뜻이기도 합니다. 프론트 요구에 따라 서버가 끌려다니는 게 아니라, 도메인부터 제대로 잡고 그 위에 API를 설계했어야 한다는 신호였을 수 있습니다. Map은 그 흔들림을 감추는 임시방편이었던 셈입니다.
문제는 이 "편리함"의 대가가 프로젝트가 커질수록 기하급수적으로 늘어난다는 점입니다.
지양해야 하는 이유
1. Value 타입을 추적하기 어렵다
Map<String, Object>에서 Object는 말 그대로 아무 타입이나 될 수 있습니다. 컨트롤러만 봐서는 각 키가 String인지, Integer인지, List<Map<String, Object>>인지 알 방법이 없습니다.
Object userId = params.get("userId"); // String? Long? Integer?
Object items = params.get("items"); // List? Map? String(JSON)?
API 문서나 프론트엔드 코드를 역으로 추적해야만 실제 타입을 확인할 수 있습니다.
2. 타입 캐스팅이 반드시 필요하다
Object로 꺼내 쓰려면 어디선가 반드시 캐스팅이 일어납니다.
String userId = (String) params.get("userId");
Long orderId = Long.valueOf(params.get("orderId").toString());
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
캐스팅은 런타임에만 검증되기 때문에, 프론트에서 숫자를 문자열로 보내거나 그 반대의 경우가 발생하면 ClassCastException이 운영 환경에서 터집니다. 컴파일러가 잡아줄 수 있는 문제를 런타임으로 미루는 셈입니다.
3. Null 처리가 복잡해진다
Map은 키 자체가 없는 경우와 키는 있지만 값이 null인 경우를 구분해야 합니다.
// 키가 없어서 null인가? 값이 null로 들어왔나?
if (params.get("optionalField") == null) {
// 어느 쪽인지 모름
}
if (params.containsKey("optionalField")) {
// 이제 구분되지만 코드가 장황해짐
}
DTO를 쓰면 필드 존재 여부는 타입 시스템이, 값의 null 여부는 @NotNull 같은 검증 어노테이션이 처리해줍니다.
4. Key 구조를 파악하기 어렵다
저는 중간에 합류한 프로젝트에서 이 문제를 직접 겪었습니다. 기존 코드를 처음 받아봤을 때 컨트롤러 파라미터가 대부분 Map<String, Object>였는데, 어떤 키가 넘어오는지 코드만 봐서는 알 수 없었습니다. 초기 개발 속도를 우선하느라 API 문서도 갖춰지지 않은 상태였고요. 결국 어떤 값이 어떤 타입으로 내려오는지 확인하려고 직접 서비스를 호출해보거나, 프론트 코드를 역으로 따라가며 하나하나 파악해야 했습니다. 간단한 수정 하나에도 시간이 배로 들었습니다.
아래 같은 코드가 대표적입니다.
@PostMapping("/orders")
public void createOrder(@RequestBody Map<String, Object> params) {
orderService.process(params);
}
이 엔드포인트가 어떤 필드를 받는지 코드 어디에도 명시되어 있지 않습니다. 서비스, 레포지토리, 심지어 SQL 쿼리까지 타고 내려가며 params.get("...")을 하나하나 추적해야 전체 그림이 보입니다.
DTO라면 클래스 선언만 봐도 전체 스펙이 드러납니다.
public class CreateOrderRequest {
private String userId;
private Long productId;
private Integer quantity;
private String couponCode; // optional
}
5. Key 오타가 디버깅 지옥을 만든다
String userId = (String) params.get("userid"); // 대소문자 오타
IDE는 "userid"가 잘못된 키라고 알려주지 않습니다. 컴파일도 성공하고, 서버도 정상 기동하고, 그저 값이 null로 나올 뿐입니다. 이 null이 어디서 왔는지 추적하는 데 반나절을 쓸 수 있습니다.
DTO라면 request.getUserid()는 존재하지 않는 메서드이므로 컴파일 시점에 바로 걸립니다.
6. 불변성 관리가 어렵다 — 파라미터가 마음대로 변경된다
Map은 기본적으로 가변(mutable) 컬렉션입니다. 여러 레이어를 거치는 동안 누가 언제 put/remove를 했는지 추적하기 매우 어렵습니다.
public void somethingMethod(Map<String, Object> params) {
params.put("key", value); // 호출자 모르게 키 추가
params.remove("sensitive"); // 호출자 모르게 키 제거
}
public void myMethod() {
Map<String, Object> somethingParams = new HashMap<>();
somethingParams.put("any", "myValue");
somethingMethod(somethingParams);
somethingParams.get("any"); // 예상과 다른 결과가 나올 수 있음
}
호출자 입장에서는 자기가 만든 Map이 메서드 호출 후에 바뀌어 있을 거라고 예상하기 어렵습니다. 문서 없이는 추적이 불가능하고, 이런 코드가 몇 개만 쌓여도 버그 원인 분석이 매우 힘들어집니다.
대안: DTO 사용
위의 모든 문제는 DTO를 쓰면 대부분 사라집니다.
public class CreateUserRequest {
@NotBlank
private String userId;
@Email
private String email;
@Min(0)
private Integer age;
// getters, (필요하다면) builder
}
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody @Valid CreateUserRequest request) {
return userService.create(request);
}
이렇게만 바꿔도 앞서 이야기한 문제들이 거의 다 해결됩니다. 필드 타입이 명시되어 있으니 타입 추적이나 캐스팅이 필요 없고, 없는 필드에 접근하면 컴파일 에러가 나니 오타 문제도 사라집니다. 필드를 final로 선언하고 Setter를 없애면 불변 객체로 만들 수도 있습니다.
덤으로 따라오는 이점도 많습니다. @NotNull, @Email, @Size 같은 Bean Validation을 그대로 쓸 수 있고, Swagger/OpenAPI를 쓰고 있다면 DTO 필드 기반으로 API 문서가 자동 생성됩니다. IDE에서 request.만 쳐도 모든 필드가 자동완성되고, 필드명을 바꿀 때도 사용처를 IDE가 전부 찾아줍니다.
레거시에서 DTO로 옮겨가기
원론적으로는 DTO를 쓰면 되지만, 이미 Map 기반으로 운영 중인 코드를 한꺼번에 다 뜯어고치는 건 현실적으로 어렵습니다. 테스트 커버리지가 충분하지 않은 상태에서 전면 리팩토링을 시도하면 오히려 운영 장애를 부를 수 있고, 그 정도 규모의 PR은 리뷰도 쉽지 않습니다.
그래서 저는 보이스카웃 원칙을 따랐습니다. "캠핑장을 떠날 때는 처음 왔을 때보다 깨끗하게 두고 가자"는 원칙을 코드에 적용한 것으로, 손대는 파일은 처음보다 조금이라도 나은 상태로 남겨두자는 태도입니다. 새로운 기능 개발 요청이 들어올 때마다 해당 엔드포인트만 Map에서 DTO로 바꾸고, 주변 코드는 건드리지 않았습니다. 요청받은 범위 안에서 크게 어긋나지 않는 선으로만 개선했습니다.
이 방식의 장점은 PR이 작아서 리뷰 부담이 적고, 운영 리스크도 낮다는 점입니다. 기능 변경과 함께 진행되니 어차피 해당 영역은 QA를 거치게 되고요. 그리고 시간이 지날수록 Map 사용처가 자연스럽게 줄어듭니다. 한 번에 다 바꾸지 못한다고 손을 놓는 것보다, 기회가 올 때마다 조금씩 개선하는 편이 오히려 지속 가능했습니다.
흔한 반론과 답변
"클래스가 너무 많아지지 않나요?"
맞습니다. DTO를 쓰면 클래스 수가 늘어납니다. 하지만 이건 비용이라기보다는 구조화된 투자에 가깝습니다. 클래스 개수가 부담이 될 정도라면 도메인 구분이 명확하지 않다는 신호일 수 있고, 오히려 이를 계기로 패키지 구조를 정리하는 것이 좋습니다.
도메인별로 패키지를 나누고 그 안에 Request/Response DTO를 두는 식으로 정리하면, 클래스가 많아져도 찾기 어렵지 않습니다. 오히려 "이 API가 받는 요청 스펙이 궁금하면 해당 DTO만 열어보면 된다"는 규칙이 생겨서 탐색이 더 빨라집니다.
"프론트 스펙이 자주 바뀌는데 DTO를 매번 고치기 번거롭습니다"
DTO를 수정해야 하는 것이 오히려 장점이기도 합니다. 스펙이 바뀌었다는 사실이 코드 변경으로 명시되고, 리뷰어도 무엇이 바뀌었는지 한눈에 볼 수 있습니다. Map을 쓰면 서버 코드는 그대로이지만 실제 동작은 달라지는, 가장 추적하기 어려운 상황이 만들어집니다.
"Map을 절대 쓰면 안 되는 건가요?"
그렇지는 않습니다. 키가 동적으로 결정되는 경우(사용자 정의 속성이나 동적 필터 같은 것), 설정값이나 메타데이터처럼 구조가 유동적인 데이터, 외부 시스템 응답을 그대로 중계하는 경우에는 Map이 오히려 자연스럽습니다.
핵심은 "편하다는 이유로" Map을 쓰지 않는 것입니다. Map을 선택할 때는 그 이유를 명확히 설명할 수 있어야 합니다.
정리
Map<String, Object>는 초기 개발에는 편리하지만, 타입 추적이 어렵고 캐스팅이 필요하며 null 처리와 오타 검증, 불변성 관리까지 모든 면에서 비용을 뒤로 미룹니다. 그 비용은 결국 몇 달 뒤 버그 원인을 추적하는 누군가가 치르게 됩니다.
새로운 컨트롤러를 만들 때는 DTO를 기본으로 쓰고, Map은 정말로 동적인 구조가 필요할 때만 예외적으로 꺼내 쓰는 것이 좋다고 생각합니다. 클래스가 늘어나는 부담보다 타입이 명시된 코드가 주는 안정감이 훨씬 큽니다.













