전체 글

반응형

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은 정말로 동적인 구조가 필요할 때만 예외적으로 꺼내 쓰는 것이 좋다고 생각합니다. 클래스가 늘어나는 부담보다 타입이 명시된 코드가 주는 안정감이 훨씬 큽니다.

반응형
반응형

요약

@MappedTypes(Set.class) 로 등록한 Custom TypeHandler 가 INSERT 에서 동작하지 않는다면, Mapper XML 의 parameterType 속성을 명시했는지부터 확인하시면 됩니다.

 

parameterType 이 없으면 MyBatis 는 파라미터를 Object 로 취급합니다. 그래서 런타임에 값 객체의 실제 클래스(LinkedHashSet 등) 로 TypeHandler 를 다시 찾는데, TypeHandlerRegistry 는 슈퍼클래스만 탐색할 뿐 인터페이스 계층은 따라가지 않습니다.

 

결국 Set 인터페이스로 등록한 핸들러는 매칭되지 않고, ObjectTypeHandler 로 폴백되어 ps.setObject(i, hashSet) 가 호출됩니다.


문제 상황

Set<PayMethod> 를 콤마 문자열로 저장하려고 Custom TypeHandler 를 작성했습니다.

@MappedTypes(Set.class)
@MappedJdbcTypes(value = JdbcType.VARCHAR, includeNullJdbcType = true)
public class PayMethodSetTypeHandler extends BaseTypeHandler<Set<PayMethod>> { ... }

Mapper XML 은 단순했습니다.

<insert id="insertUser">
  INSERT INTO my_user (id, name, pay_methods)
  VALUES (#{id}, #{name}, #{payMethods})
</insert>

그런데 INSERT 를 실행하자 이해하기 힘든 에러가 발생했습니다.

org.h2.jdbc.JdbcSQLDataException:
  Data conversion error converting
  "CAST(X'aced0005737200176a6176612e7574696c2e4c696e6b656448617368536574...' AS JAVA_OBJECT)"

에러 메시지에 보이는 X'aced0005...'Java 직렬화 객체의 매직 넘버입니다. 즉 LinkedHashSet 이 통째로 직렬화된 바이트가 DB 로 전달되었다는 뜻입니다. TypeHandler 를 분명히 등록했는데, 왜 호출되지 않았을까요?

원인 분석

TypeHandler 내부에 로그를 남겨두고 다시 실행해봤습니다. 예상대로 핸들러가 단 한 번도 호출되지 않았습니다.

여기서 한 가지 가설을 세워봤습니다.

parameterType 을 생략하면 MyBatis 는 파라미터 타입을 Object 로 간주합니다. 그러면 파싱 시점에 #{payMethods} 의 javaType 을 추론할 수 없어 UnknownTypeHandler 가 선택되고, 런타임에 값 객체의 getClass() 로 다시 핸들러를 찾습니다.

이때 호출되는 TypeHandlerRegistry.getJdbcHandlerMapForSuperclass() 는 슈퍼클래스만 재귀적으로 타고 올라갑니다. LinkedHashSet → HashSet → AbstractSet → AbstractCollection → Object 어느 단계에도 Set 인터페이스는 없기 때문에, @MappedTypes(Set.class) 로 등록한 핸들러는 끝내 발견되지 않습니다.

두 방식을 나란히 비교해봤습니다

가설을 검증하기 위해 동일한 쿼리를 두 벌 준비했습니다.

A. parameterType 을 명시한 버전

<insert id="insertUser" parameterType="com.example.MyDto">
  INSERT INTO my_user (id, name, pay_methods)
  VALUES (#{id}, #{name}, #{payMethods})
</insert>

B. parameterType 을 생략한 버전

<insert id="insertUser">
  INSERT INTO my_user (id, name, pay_methods)
  VALUES (#{id}, #{name}, #{payMethods})
</insert>

TypeHandler 에는 호출 횟수를 기록할 카운터를 추가해두었습니다.

public static final AtomicInteger setNonNullParameterCount = new AtomicInteger(0);

결과는 다음과 같았습니다.

시나리오 TypeHandler 호출 DB 저장값 결과
A. parameterType 명시 1회 CARD,CASH 성공
B. parameterType 생략 0회 (직렬화 바이트) 예외

값 객체를 HashSet, LinkedHashSet, Collections.unmodifiableSet 으로 바꿔가며 여러 번 실행해봐도 B 는 구현체와 무관하게 동일하게 실패했습니다. 인터페이스 계층을 탐색하지 않는다는 가설과 정확히 맞아떨어지는 결과입니다.

그런데 SELECT 는 왜 멀쩡할까요?

SELECT 는 parameterType 유무와 관계없이 모두 정상 동작했습니다. 더 놀라운 건 resultType 마저 생략한 XML 도 정상 동작했다는 점입니다.

<!-- resultType / resultMap 둘 다 없음 -->
<select id="selectUser">
    SELECT id, name, pay_methods AS payMethods FROM my_user WHERE id = #{id}
</select>
MyDto selectUser(long id);

실행해보면 TypeHandler 도 제대로 호출되고, MyDtoSet<PayMethod> 가 잘 복원됩니다. MyBatis 가 인터페이스 메서드의 리턴 타입을 폴백으로 활용하고 있기 때문입니다.

진짜 경계선: INSERT/UPDATE 가 아니라, "파라미터 바인딩 vs 결과 매핑"

여기서 중요한 비대칭이 드러납니다.

경로 인터페이스 시그니처 활용 parameterType / resultType 누락 시
파라미터 바인딩 (#{}) ❌ 안 함 Object 로 폴백 → TypeHandler 매칭 실패
결과 매핑 (SELECT 리턴) 메서드 리턴 타입으로 폴백 정상 동작

왜 이런 비대칭이 생길까요?

  • 리턴 타입 경로: 런타임에 MapperMethod.execute() 가 메서드 시그니처의 리턴 타입을 확인해 selectOne / selectList / Cursor 등으로 분기하고, 결과 조립 단계에 리턴 타입 힌트를 전달합니다. 즉 실행 타임마다 메서드 정보가 다시 참조됩니다.
  • 파라미터 타입 경로: ParameterMapping 은 XML 파싱 시점에 javaType 과 TypeHandler 가 확정되어 MappedStatement 에 박혀버립니다. 이후 실행할 때 그 값을 그대로 사용할 뿐, 메서드 시그니처를 소급 반영하지 않습니다.

즉 문제가 발생하는 진짜 경계선은 "INSERT/UPDATE" 가 아니라 "파라미터 바인딩을 타는가" 입니다.

그럼 SELECT 도 위험할 수 있습니다

파라미터 바인딩은 SELECT 의 WHERE 절에서도 일어납니다. 아래처럼 커스텀 타입을 WHERE 에 넣는 순간 SELECT 에서도 동일하게 재현됩니다.

<select id="findByPayMethods" resultType="com.example.MyDto">
    SELECT * FROM my_user WHERE pay_methods = #{payMethods}
</select>
MyDto findByPayMethods(Set<PayMethod> payMethods);

INSERT 와 똑같은 경로를 타기 때문에 parameterType 이 없으면 ps.setObject 폴백이 일어납니다.

실전에서 SELECT 가 잘 안 터졌던 이유는, 조회 파라미터가 보통 long id, String status 같은 기본형/원시 타입 이기 때문입니다. 이런 타입들은 MyBatis 에 기본 TypeHandler 가 이미 등록돼 있어 parameterType 없이도 아무 문제가 없습니다.

반면 INSERT/UPDATE 는 DTO 의 여러 필드를 #{} 로 한꺼번에 풀어 넣는 과정에서 커스텀 타입이 섞여 들어가기 쉬워, 이 함정에 자주 걸립니다.

한 줄로 정리하면

Custom TypeHandler 가 무시되는 조건은 "#{} 에 커스텀 타입을 바인딩하는데 parameterType 이 없을 때" 입니다.
INSERT/UPDATE/DELETE 든 SELECT 의 WHERE 절이든 동일하게 발생합니다.
다만 결과 매핑 쪽은 XML resultType 이 없어도 인터페이스 리턴 타입으로 폴백되어 안전합니다.

"Mapper Interface 에 MyDto 가 선언돼 있는데, 왜 그걸 못 읽어오나요?"

인터페이스에 void insertUser(MyDto dto) 가 분명히 있으니, MyBatis 가 그 정보를 parameterType 으로 그대로 써주면 될 것 같죠.

하지만 실제로는 그렇게 동작하지 않습니다. MyBatis 는 XML 파싱과 Mapper Interface 바인딩을 분리된 단계로 처리하기 때문입니다.

 

XML 파싱 시점에는 어떤 인터페이스 메서드에 연결될지 모른 채 parameterType 속성만 보고 MappedStatement 를 완성합니다. 이후 Mapper 바인딩 단계에서 메서드 시그니처는 @Param 이름 매핑과 반환 타입 처리에만 쓰일 뿐, 이미 확정된 parameterType 은 덮어쓰지 않습니다.

 

그래서 인터페이스에 MyDto 가 있어도, XML 에 parameterType 이 없으면 MyBatis 는 파라미터를 Object 로 취급합니다. (리턴 타입 쪽은 앞서 본 것처럼 실행 타임마다 메서드 시그니처를 다시 참조하기 때문에 XML 에 resultType 이 없어도 인터페이스 리턴 타입으로 폴백됩니다 — 파라미터 쪽만 이 폴백이 없는 것입니다.)

그럼 @Insert 어노테이션으로 쓰면요?

어노테이션 기반으로 작성하면 이야기가 달라집니다. 어노테이션 처리 단계에서는 메서드 시그니처에서 파라미터 타입을 직접 추론하므로, 같은 코드라도 TypeHandler 가 제대로 호출됩니다.

@Insert("INSERT INTO my_user (id, name, pay_methods) VALUES (#{id}, #{name}, #{payMethods})")
void insertUser(MyDto dto);   // parameterType 이 MyDto 로 추론됨

즉 MyBatis 가 "메서드 시그니처를 읽지 못하는 것" 이 아니라, XML 경로에서만 그 정보를 활용하지 않는 것입니다.

경로 parameterType 결정 방법
XML 매퍼 XML 의 parameterType 속성만 사용 (메서드 시그니처 무시)
어노테이션 매퍼 메서드 시그니처에서 추론
혼용 시 XML 이 우선, 어노테이션 무시

그래서 이 버그는 XML 매퍼를 쓸 때만 발생합니다. 같은 로직을 @Insert 로 작성했다면 애초에 문제되지 않았을 거고요.

해결 방법

세 가지 선택지가 있습니다.

parameterType 속성을 명시한다 — 가장 간단

<insert id="insertUser" parameterType="com.example.MyDto">
  ...
</insert>

#{} 표현식에 직접 typeHandler 를 지정한다

VALUES (
  #{id},
  #{name},
  #{payMethods, typeHandler=com.example.PayMethodSetTypeHandler}
)

@MappedTypes 에 구체 구현체를 모두 나열한다 — 권장하지 않음

@MappedTypes({HashSet.class, LinkedHashSet.class, ...})

새로운 구현체가 등장할 때마다 추가해야 하므로 취약합니다.

정리

Custom TypeHandler 가 "분명히 등록했는데 호출되지 않는" 증상의 흔한 원인은, MyBatis 의 TypeHandler 탐색이 인터페이스 계층을 따라가지 않는다는 점입니다. 기억할 포인트는 두 가지입니다.

  • 파싱 타임 해결: parameterType 이 있을 때. 클래스의 필드/프로퍼티 선언 타입으로 결정됩니다.
  • 런타임 해결: parameterType 이 없을 때. 값 객체의 getClass() 로 결정되며, 인터페이스는 무시됩니다.

XML 에 parameterType 한 줄을 추가하면 모든 해결 과정이 파싱 타임으로 옮겨가고, 인터페이스로 등록한 TypeHandler 도 안정적으로 호출됩니다. 혹시 비슷한 증상을 만나셨다면, 가장 먼저 이 속성을 확인해보시면 좋을 것 같습니다.

반응형
반응형

Spring Boot 애플리케이션을 실행할 때 프로파일을 지정하는 방법은 크게 두 가지다.

java -Dspring.profiles.active=prod -jar app.jar
java -jar app.jar --spring.profiles.active=prod

둘 다 동작하지만, 동작하는 이유와 위치가 다르다. 이 차이를 모르면 "분명히 옵션을 줬는데 프로파일이 적용이 안 되네?" 같은 상황에서 시간을 낭비하기 쉽다.

두 방식의 본질적인 차이

-Dspring.profiles.active=prod — JVM 시스템 프로퍼티

-D 옵션은 JVM에게 전달되는 시스템 프로퍼티다. 자바 코드에서는 System.getProperty("spring.profiles.active")로 읽을 수 있다.

Spring의 Environment는 JVM 시스템 프로퍼티를 자동으로 읽어들이기 때문에, 이 방식으로 지정한 값이 프로파일로 활성화된다.

--spring.profiles.active=prod — 애플리케이션 커맨드라인 인자

--로 시작하는 인자는 애플리케이션에게 전달된다. public static void main(String[] args)args 배열에 문자열로 들어온다.

Spring Boot의 SpringApplication이 실행될 때 이 인자들을 파싱해서 EnvironmentcommandLineArgs라는 PropertySource로 등록한다.

즉, 같은 결과를 내지만 경로가 완전히 다르다.

항목 -Dspring.profiles.active --spring.profiles.active
전달 대상 JVM 애플리케이션
명령어 내 위치 -jar -jar
읽는 주체 System.getProperty() main(String[] args)
PropertySource 이름 systemProperties commandLineArgs

위치를 바꾸면 어떻게 될까

위치가 바뀌면 조용히 실패하거나 에러가 난다.

# -D를 jar 뒤에 두는 경우 (잘못됨)
java -jar app.jar -Dspring.profiles.active=prod

실행되는 프로파일이 default로 표시된다

이 경우 -Dspring.profiles.active=prod는 JVM 옵션이 아니라 애플리케이션에게 전달되는 인자로 취급된다. 그런데 Spring Boot는 --로 시작하는 인자만 프로퍼티로 파싱하기 때문에, 이 값은 그냥 무시된다. 에러도 나지 않고 조용히 누락되기 때문에 디버깅이 까다롭다.

# --를 jar 앞에 두는 경우 (잘못됨)
java --spring.profiles.active=prod -jar app.jar

이 경우는 java 실행 파일이 --spring.profiles.active=prod를 자신의 옵션으로 해석하려다가 실패해서 바로 에러가 난다.

두 방식을 동시에 쓰면 누가 이기는가

둘 다 같이 넣으면 어떻게 될까?

java -Dspring.profiles.active=dev -jar app.jar --spring.profiles.active=prod

결과는 prod 가 활성화된다. 커맨드라인 인자(--)가 시스템 프로퍼티(-D)보다 우선순위가 높기 때문이다.

PropertySource 우선순위

Spring Boot는 여러 소스에서 설정값을 읽어들이고, 중복된 키가 있으면 우선순위에 따라 결정한다. 공식 문서(Externalized Configuration)에 정의된 순서를 단순화하면 대략 이렇다 (위가 우선순위 높음):

  1. Devtools 글로벌 설정 (~/.config/spring-boot/)
  2. @TestPropertySource 애노테이션 (테스트용)
  3. properties 애트리뷰트가 붙은 @SpringBootTest (테스트용)
  4. 커맨드라인 인자 (--key=value)
  5. SPRING_APPLICATION_JSON 환경변수
  6. ServletConfig init 파라미터
  7. ServletContext init 파라미터
  8. JNDI 속성 (java:comp/env)
  9. JVM 시스템 프로퍼티 (-Dkey=value)
  10. OS 환경변수
  11. RandomValuePropertySource
  12. 프로파일별 application-{profile}.properties (jar 외부)
  13. 프로파일별 application-{profile}.properties (jar 내부)
  14. application.properties (jar 외부)
  15. application.properties (jar 내부)
  16. @PropertySource 애노테이션
  17. 기본 속성 (SpringApplication.setDefaultProperties)

핵심만 보면:

커맨드라인 인자 (--)  >  시스템 프로퍼티 (-D)  >  환경변수  >  application.yml

즉, 운영 환경에서 설정을 덮어쓰고 싶을 때는 --가 가장 강력하고, application.yml에 기본값을 두고 외부에서 주입받는 계층 구조를 만들기 좋다.

정리

  • -D는 JVM 옵션이므로 -jar 에, --는 애플리케이션 인자이므로 -jar 에 온다.
  • 위치를 헷갈리면 조용히 무시되거나 에러가 난다. 특히 -D를 뒤에 두는 실수는 에러가 안 나서 디버깅이 어렵다.
  • 둘 다 지정된 경우 --가 우선한다.
  • 우선순위 큰 틀: 커맨드라인 인자 > 시스템 프로퍼티 > 환경변수 > application.yml.
  • 컨테이너 환경에서는 환경변수(SPRING_PROFILES_ACTIVE)가 가장 자연스럽다.

옵션이 어디서 어떻게 읽히는지 이해하면, "왜 프로파일이 적용 안 되지?" 같은 상황에서 빠르게 원인을 짚을 수 있다.

반응형
반응형

내 프로젝트를 직접 만든 도메인으로 배포하고 싶다면? 가비아에서 저렴하게 도메인을 구입하고 Netlify에 연결하는 전 과정을 정리했습니다.


목차

  1. 가비아에서 도메인 구입하기
  2. Netlify 팀 DNS에 도메인 등록하기
  3. 가비아에서 네임서버 변경하기
  4. Netlify 사이트에 도메인 연결하기
  5. 마무리 및 주의사항

1. 가비아에서 도메인 구입하기

1-1. 가비아 접속 및 로그인

가비아 홈페이지에 접속한 후 로그인합니다. 계정이 없다면 회원가입을 먼저 진행하세요.

1-2. 도메인 검색

상단 검색창에 원하는 도메인 이름을 입력하고 검색합니다.

예) my-awesome-project

검색 결과에서 사용 가능한 도메인 목록이 표시됩니다. .com, .co.kr, .net 등 원하는 확장자를 선택합니다.

Tip: .com 도메인이 국제적으로 범용성이 높습니다. 국내 서비스라면 .co.kr도 좋은 선택입니다.

1-3. 장바구니 추가 및 결제

원하는 도메인 옆의 신청 버튼을 클릭하여 장바구니에 추가합니다.
장바구니에서 기간(1년 ~ 10년)을 선택하고 결제를 진행합니다.
결제가 완료되면 My 가비아 > 서비스 관리 > 도메인 관리에서 구매한 도메인을 확인할 수 있습니다.


2. Netlify 팀 DNS에 도메인 등록하기

왜 팀 DNS에 먼저 등록할까요?
Netlify의 DNS는 팀(Team) 레벨에서 관리됩니다. 사이트별 설정에서 도메인을 추가하기 전에 팀 DNS에 먼저 도메인을 등록해두면, 네임서버 주소를 미리 확인하고 여러 사이트에서 같은 도메인을 유연하게 관리할 수 있습니다.

2-1. 팀 DNS 메뉴로 이동

Netlify에 로그인한 후, 왼쪽 사이드바에서 팀 이름 아래의 DNS 메뉴를 클릭합니다.

2-2. 도메인 추가

Use Netlify DNS 버튼을 클릭하고, 가비아에서 구입한 도메인을 입력합니다.

예) my-awesome-project.com

입력 후 VerifyAdd domain 을 클릭합니다.

2-3. 네임서버 주소 확인

도메인이 추가되면 Netlify가 네임서버(Nameserver) 주소 4개를 제공합니다.

아래와 같은 형식입니다 (실제 값은 팀마다 다릅니다):

dns1.p01.nsone.net
dns2.p01.nsone.net
dns3.p01.nsone.net
dns4.p01.nsone.net

이 네임서버 주소를 복사해두세요. 다음 단계에서 가비아에 입력해야 합니다.


3. 가비아에서 네임서버 변경하기

3-1. 가비아 도메인 관리 페이지로 이동

가비아 로그인 후 My 가비아서비스 관리도메인 으로 이동합니다.

3-2. 네임서버 설정 진입

구입한 도메인 옆의 관리 버튼을 클릭합니다.

도메인 상세 페이지에서 네임서버 항목을 찾아 설정 또는 변경 버튼을 클릭합니다.

3-3. Netlify 네임서버 입력

기존에 설정된 가비아 기본 네임서버를 지우고, 2단계에서 복사한 Netlify 네임서버 4개를 각각 입력합니다.

순서 네임서버 주소
1차 dns1.p01.nsone.net
2차 dns2.p01.nsone.net
3차 dns3.p01.nsone.net
4차 dns4.p01.nsone.net

(위 주소는 예시입니다. Netlify에서 제공된 실제 주소를 입력하세요)

입력 완료 후 적용 또는 저장 버튼을 클릭합니다.

DNS 전파에는 최대 24~48시간이 소요될 수 있습니다. 보통은 1~2시간 내에 완료됩니다. https://dnschecker.org/ 를 통해 현재 DNS가 전파되었는지 확인할 수 있습니다.


4. Netlify 사이트에 도메인 연결하기

가비아 네임서버 변경 후 DNS가 전파되면, 이제 특정 사이트와 도메인을 연결합니다.

4-1. 사이트 Domain management로 이동

연결할 사이트를 선택한 후 Site configurationDomain management 로 이동합니다.

Production Domains > Add domain alias 버튼을 클릭하고 동일한 도메인을 입력합니다.

Netlify 팀 DNS에 이미 등록된 도메인이므로 자동으로 인식됩니다.

4-2. DNS 전파 상태 확인

도메인 항목의 상태 표시를 확인합니다.

  • Awaiting External DNS — DNS 전파 대기 중 (정상, 시간이 필요함)
  • Netlify DNS — 연결 완료!

DNS 전파에는 최대 24~48시간이 소요될 수 있습니다. 보통은 1~2시간 내에 완료됩니다.

4-3. HTTPS 자동 설정 확인

도메인 연결이 완료되면 Netlify가 Let's Encrypt SSL 인증서를 자동으로 발급합니다.

Domain managementHTTPS 섹션에서 인증서 상태를 확인할 수 있습니다.

HTTPS가 활성화되면 http:// 로 접근해도 자동으로 https:// 로 리다이렉트됩니다.

4-4. 실제 접속 확인

브라우저에서 내 도메인을 직접 입력해 사이트가 정상적으로 열리는지 확인합니다.

https://my-awesome-project.com

5. 마무리 및 주의사항

주의사항

  • 네임서버 변경 후 바로 접속이 안 되는 것은 정상입니다. DNS 전파 시간(최대 48시간)을 기다리세요.
  • 가비아의 기본 네임서버를 완전히 대체하는 방식이므로, 가비아 DNS 설정은 더 이상 적용되지 않습니다.
  • 이미 가비아에서 이메일 호스팅 등 다른 서비스를 사용 중이라면, 네임서버 변경 전 해당 서비스의 DNS 레코드를 Netlify DNS에 추가해야 합니다.
  • DNS 전파 상태는 whatsmydns.net에서 확인할 수 있습니다.

반응형
반응형

가비아에서 도메인을 구매하고 Netlify에 연결하는 과정에서 꽤 많은 시행착오를 겪었다. 특히 서브도메인을 추가하려다 막히는 부분이 있었는데, 같은 상황에서 헤매는 사람이 있을 것 같아 정리해둔다.


네임서버 위임부터 이해하자

가비아에서 도메인을 구매하면 기본적으로 가비아의 네임서버가 등록되어 있다. Netlify DNS를 사용하려면 이 네임서버를 Netlify 것으로 교체해야 한다.

Netlify가 제공하는 네임서버는 각 계정마다 다를수 있으므로 Netlify 메뉴중 DNS에서 확인하면 된다.

가비아 도메인 관리 페이지에서 위 네임서버로 교체하면 된다.
가비아 로그인 > My가비아 > 도메인 통합 관리툴 > 도메인 선택 > 네임서버(설정)

이 작업이 완료되면 도메인의 DNS 관리 권한이 Netlify로 완전히 넘어간다. 이후 DNS 설정은 가비아가 아닌 Netlify에서 해야 한다는 뜻이다. 가비아에서 아무리 레코드를 추가해봤자 적용되지 않는다.


Netlify DNS 메뉴와 사이트별 Domain management, 뭐가 다른가

Netlify를 처음 쓸 때 헷갈렸던 부분이 바로 이것이다. DNS를 설정하는 곳이 두 군데처럼 느껴졌다.

Netlify 루트의 Domains 메뉴는 도메인 전체의 DNS 레코드를 관리하는 곳이다. A 레코드, CNAME, NETLIFY 레코드 등을 직접 추가하거나 삭제할 수 있고, 현재 등록된 네임서버도 이곳에서 확인할 수 있다.

각 사이트의 Domain management는 특정 Netlify 사이트에 어떤 도메인을 연결할지 설정하는 곳이다. 여기서 도메인을 등록하면 Netlify가 필요한 DNS 레코드를 자동으로 생성해준다.

정리하면 이렇다.

  • 도메인 전체 DNS 현황 확인 → Domains 메뉴
  • 특정 사이트에 도메인 연결 → 해당 사이트의 Domain management

서브도메인 추가할 때 주의할 점

json.toolzy.site 같은 서브도메인을 추가하고 싶어서 Domains 메뉴에서 직접 CNAME을 추가하려 했더니 이런 경고가 떴다.

Potentially conflicting DNS record
CNAMEs pointing to *.netlify.app are usually unnecessary
and can conflict with Netlify-managed records.

처음엔 그냥 "Add anyway"를 눌러야 하나 고민했는데, 올바른 방법은 따로 있었다.

서브도메인을 연결할 Netlify 사이트로 이동해서, Domain management에서 해당 서브도메인을 직접 등록하는 것이다. 그러면 Netlify가 DNS 레코드를 알아서 처리해준다. 루트 DNS 메뉴에서 수동으로 CNAME을 추가할 필요가 없다.


Primary Domain을 잘못 설정하면 생기는 일

Domain management에서 json.toolzy.site를 Primary Domain으로 설정했더니 사이트에 접근이 안 됐다. 이유를 찾아보니 Netlify의 Primary Domain 동작 방식 때문이었다.

Netlify는 Primary Domain으로 모든 트래픽을 리다이렉트한다. Primary가 아닌 도메인은 전부 Primary로 301 리다이렉트되는 alias 역할을 한다.

json.toolzy.site를 Primary로 설정하면 이런 일이 벌어진다.

toolzy.site 접속     ->  json.toolzy.site로 리다이렉트
www.toolzy.site 접속 ->  json.toolzy.site로 리다이렉트

루트 도메인이 서브도메인으로 리다이렉트되는 이상한 구조가 만들어지고, 결국 트래픽 흐름이 꼬인다.

올바른 설정은 다음과 같다.

toolzy.site (Primary)     ->  정상 서빙
json.toolzy.site (alias)  ->  정상 서빙

toolzy.site를 Primary로 두고, json.toolzy.site는 alias로 추가하면 각 도메인이 독립적으로 동작한다.


새 서브도메인 사이트를 추가하는 올바른 순서

나중에 jpegconverter.toolzy.site 같은 서브도메인을 추가해야 할 때를 위해 순서를 정리해둔다.

1단계. 새 사이트를 Netlify에 배포한다. (jpegconverter.netlify.app 생성)

2단계. 해당 사이트의 Domain management로 이동한다.

3단계. Add domain alias에서 jpegconverter.toolzy.site를 입력하고 Primary Domain으로 설정한다.

4단계. Netlify가 DNS 레코드를 자동으로 생성해준다. 루트 Domains 메뉴에서 별도 작업은 필요 없다.

각 사이트는 독립적으로 도메인을 관리하며, 구조는 다음과 같이 유지된다.

Netlify 사이트 Primary Domain
jformatter.netlify.app toolzy.site
jformatter.netlify.app json.toolzy.site (alias)
jpegconverter.netlify.app jpegconverter.toolzy.site

메인 사이트가 없을 때는 어떻게 하나

루트 도메인(toolzy.site)을 연결할 메인 사이트가 따로 없다면 두 가지 방법이 있다.

첫 번째는 서브 사이트 중 하나를 임시로 메인에 연결하는 방법이다. 간단하지만 특정 툴이 메인처럼 보이는 어색함이 있다.

두 번째는 랜딩 페이지 사이트를 별도로 만드는 방법이다. 간단한 index.html만 있어도 충분하고, 툴이 늘어날수록 허브 역할을 하게 되어 SEO와 사용자 경험 면에서도 낫다.

지금 당장 랜딩 페이지를 만들 여유가 없다면, 임시로 서브 사이트를 메인에 연결해두고 나중에 교체하는 방식으로 진행해도 전혀 문제없다.


요약

  • 네임서버를 Netlify로 위임하면 이후 DNS 설정은 전부 Netlify에서 한다.
  • 서브도메인 추가는 루트 DNS 메뉴가 아니라 해당 사이트의 Domain management에서 한다.
  • Primary Domain은 최종 목적지다. 서브도메인을 Primary로 설정하면 루트 도메인이 서브도메인으로 리다이렉트되는 구조가 된다.
  • 각 사이트는 자신의 서브도메인을 Primary로 설정하고 독립적으로 관리한다.
반응형
반응형

요약

필터에서 request.getInputStream() 으로 body 를 읽고 나면, Controller 의 @RequestBodyHttpMessageNotReadableException: Required request body is missing 이라고 예외를 던집니다.

 

HttpServletRequest 의 InputStream 은 네트워크 소켓에서 직접 읽어오는 일회성 스트림이기 때문입니다. 한 번 소비하면 끝이고, reset() 도 지원하지 않습니다.

 

해결은 어렵지 않습니다. Spring 이 제공하는 ContentCachingRequestWrapper 로 요청을 감싸서 필터 체인에 넘기면 끝입니다. 단, 캐시된 내용은 filterChain.doFilter() 이후에 읽어야 합니다.


문제 상황

로깅 필터에서 요청 본문을 남기려고 이런 코드를 작성했습니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        log.info("Request Body: {}", body);

        chain.doFilter(request, response);
    }
}

Controller 는 평범한 @RequestBody 입니다.

@PostMapping
public ResponseEntity<User> createUser(@RequestBody UserRequest req) { ... }

실행하면 Controller 에서 이런 예외가 터집니다.

HttpMessageNotReadableException: Required request body is missing

필터에서는 body 를 분명히 읽었는데, Controller 가 "본문이 비어 있다" 고 말하는 상황입니다.

원인 분석

ServletInputStream 의 동작을 다시 떠올려보면 답이 나옵니다.

getInputStream() 이 돌려주는 스트림은 네트워크 소켓에서 직접 바이트를 당겨오는 일회성 스트림입니다. 한 번 읽으면 내부 버퍼가 소비되고, mark/reset 도 지원하지 않습니다. Spring 의 @RequestBody 처리는 내부적으로 HttpMessageConverter 가 다시 getInputStream() 을 호출해 JSON 을 파싱하는 구조라서, 필터에서 먼저 읽어버리면 Converter 입장에서는 빈 스트림을 마주하게 됩니다.

요청 처리 순서로 적어보면 이렇습니다.

  1. 클라이언트가 JSON body 를 담아 POST 요청
  2. 필터에서 getInputStream() 호출 → 스트림 소비됨
  3. filterChain.doFilter() 로 DispatcherServlet 에 전달
  4. @RequestBody 처리 시 HttpMessageConverter 가 다시 getInputStream() 호출
  5. 빈 스트림 → HttpMessageNotReadableException

"필터와 Controller 가 같은 요청 객체를 공유하니 body 도 공유될 것이다" 라는 직관이 깨지는 지점입니다. 공유되는 건 요청 객체지, 스트림 안의 바이트가 아닙니다.

해결: ContentCachingRequestWrapper

Spring 이 정확히 이 용도로 ContentCachingRequestWrapper 를 제공합니다. 요청을 감싸두면, 내부에서 body 를 읽을 때마다 바이트를 별도 버퍼에 복사해 보관합니다. 원본 스트림은 @RequestBody 가 정상적으로 소비하고, 우리는 버퍼에서 사본을 꺼내 보면 됩니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request);

        chain.doFilter(wrapped, response);   // ← 먼저 체인을 실행해야 한다

        byte[] content = wrapped.getContentAsByteArray();
        if (content.length > 0) {
            log.info("Request Body: {}", new String(content, wrapped.getCharacterEncoding()));
        }
    }
}

여기서 헷갈리기 쉬운 포인트가 하나 있습니다.

getContentAsByteArray()filterChain.doFilter() 가 끝난 뒤에 호출해야 합니다. 캐시는 누군가가 getInputStream() 을 실제로 읽어야 채워지는데, 그 "누군가" 가 바로 뒤의 Controller(정확히는 HttpMessageConverter)이기 때문입니다. 체인 실행 전에 호출하면 빈 배열만 돌아옵니다.

로깅을 "먼저" 하고 싶다면

"요청이 들어오자마자 body 를 찍고 싶은데, 체인이 끝난 뒤에야 읽을 수 있다니 불편하다" 는 경우가 있습니다. 이때는 두 가지 방법이 있습니다.

① 그냥 체인 끝난 뒤에 찍습니다. 어차피 로그 한 줄의 위치 차이라서, 대부분은 이걸로 충분합니다. 시간 측정까지 같이 할 수 있다는 장점이 있습니다.

② 직접 래퍼를 만들어 생성 시점에 바이트를 읽어둡니다. "진짜로 필터 진입 직후에 body 가 필요하다" 면 이 방법을 씁니다.

public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {

    private final byte[] cachedBody;

    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedBodyServletInputStream(cachedBody);
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(cachedBody)));
    }

    public byte[] getCachedBody() {
        return cachedBody;
    }
}

ServletInputStream 은 추상 메서드 4개(isFinished, isReady, setReadListener, read)를 구현해주는 뼈대 클래스를 하나 만들어두면 됩니다. 특별한 요구가 없다면 ①이 훨씬 덜 번거롭습니다.

Best Practice: 요청/응답 통합 로깅

실무에서는 요청만 찍고 끝나는 경우가 거의 없습니다. 보통 응답까지 같이 찍어 성능 측정이나 감사 로그로 쓰는 패턴이 자리잡습니다. 응답도 ContentCachingResponseWrapper 로 같은 방식으로 감쌉니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class RequestResponseLoggingFilter extends OncePerRequestFilter {

    private static final int MAX_PAYLOAD = 10_000;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        ContentCachingRequestWrapper  req = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(response);

        long start = System.currentTimeMillis();
        try {
            chain.doFilter(req, res);
        } finally {
            long duration = System.currentTimeMillis() - start;

            log.info("[{} {}] {} ({}ms) req={} res={}",
                req.getMethod(), req.getRequestURI(), res.getStatus(), duration,
                toString(req.getContentAsByteArray(), req.getCharacterEncoding()),
                toString(res.getContentAsByteArray(), res.getCharacterEncoding()));

            res.copyBodyToResponse();   // ← 반드시 호출
        }
    }

    private String toString(byte[] buf, String encoding) {
        if (buf.length == 0) return "";
        String s = new String(buf, encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding));
        return s.length() > MAX_PAYLOAD ? s.substring(0, MAX_PAYLOAD) + "...(truncated)" : s;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/static") || uri.startsWith("/css")
            || uri.startsWith("/js")     || uri.endsWith(".ico");
    }
}

여기서도 함정이 하나 있습니다.

ContentCachingResponseWrapper 는 응답 바이트를 자기 버퍼에만 담아두기 때문에, copyBodyToResponse() 를 호출하지 않으면 클라이언트는 빈 응답을 받습니다. 요청 래퍼는 "읽기" 만 캐싱하지만, 응답 래퍼는 "쓰기" 를 가로채는 구조라 이 차이가 생깁니다.

추가로 신경 쓸 것 몇 가지만 적어둡니다.

  • 파일 업로드(multipart/form-data)는 래퍼 대상에서 제외합니다. 수 MB~수십 MB 가 그대로 메모리 버퍼에 복사됩니다.
  • MAX_PAYLOAD 같은 잘라내기 상한을 둡니다. 사고로 큰 응답이 로그로 쏟아지는 걸 막을 수 있습니다.
  • 정적 리소스는 shouldNotFilter 로 스킵합니다. 로그 노이즈가 크게 줄어듭니다.

정리

  • HttpServletRequest 의 InputStream 은 한 번만 읽을 수 있습니다. 필터에서 먼저 읽으면 @RequestBody 가 빈 본문을 만납니다.
  • ContentCachingRequestWrapper 로 감싸서 체인에 넘기고, 체인 실행 후 getContentAsByteArray() 로 읽으면 안전하게 양쪽에서 body 를 볼 수 있습니다.
  • 응답까지 찍을 거라면 ContentCachingResponseWrapper 를 함께 쓰되, copyBodyToResponse() 를 잊지 마세요. 이거 하나 빠뜨려서 "운영에서만 빈 응답이 내려간다" 는 버그로 이어지기 쉽습니다.

비슷한 증상을 만나셨다면, 필터가 body 를 먼저 건드리고 있는지부터 확인해보시면 좋을 것 같습니다.


참고 자료

반응형
반응형

데이터베이스에서 기간과 기간이 겹치는 데이터를 찾을 때, 복잡한 OR 조건 대신 두 개의 간단한 비교 조건만으로 모든 경우를 처리할 수 있다.

WHERE DB_시작일 <= 검색_종료일 AND DB_종료일 >= 검색_시작일

1. 들어가며

커머스 서비스를 개발하다 보면 이벤트, 프로모션, 예약 등 시작일과 종료일을 가진 데이터를 검색해야 하는 경우가 정말 많다.
단순히 특정 날짜가 포함된 데이터를 찾는 건 간단한데, 사용자가 입력한 기간과 DB에 저장된 기간이 겹치는 모든 경우를 찾는 건 생각보다 복잡하다. 이 글에서는 단 두 줄로 모든 경우를 처리하는 효율적인 쿼리 작성법을 알아보려고 한다.

2. 문제 상황

사용자가 2023년 9월 7일부터 10월 7일까지의 이벤트를 검색할 때, 많은 개발자들이 이렇게 작성한다.

SELECT *
FROM MY_TABLE
WHERE
    EVENT_ST_DATE BETWEEN '2023-09-07' AND '2023-10-07'
    OR EVENT_ED_DATE BETWEEN '2023-09-07' AND '2023-10-07'
    OR '2023-09-07' BETWEEN EVENT_ST_DATE AND EVENT_ED_DATE
    OR '2023-10-07' BETWEEN EVENT_ST_DATE AND EVENT_ED_DATE
  • 4개의 OR 조건으로 가독성이 떨어진다
  • 조건을 누락하거나 잘못 작성하기 쉽다
  • 인덱스 활용이 비효율적이다

3. 해결 방법

SELECT *
FROM MY_TABLE
WHERE
    EVENT_ST_DATE <= '2023-10-07'     -- DB 시작이 검색 종료 이전
    AND EVENT_ED_DATE >= '2023-09-07' -- DB 종료가 검색 시작 이후

이렇게 사용하자.

WHERE
    {DB_시작일} <= {검색_종료일}
    AND {DB_종료일} >= {검색_시작일}

장점

  • 단 두 줄로 모든 경우를 처리 할수 있다
  • 가독성이 훨씬 좋다
  • AND 조건으로 인덱스 활용이 효율적이다
  • 실수할 여지가 적다
  • 모든 데이터베이스에서 동일하게 작동한다

4. 정리

데이터베이스에서 기간과 기간이 겹치는 데이터를 검색할 때는 겹치지 않는 경우의 반대를 생각하는 게 핵심이다.
복잡한 OR 조건 대신, 다음 두 가지 조건만 기억하자.

WHERE DB_시작일 <= 검색_종료일 AND DB_종료일 >= 검색_시작일

이 간단한 패턴으로 모든 경우를 빠짐없이 처리할 수 있고, 가독성과 성능 면에서도 우수하다

반응형
반응형

1. 요약

실행 순서: @Order 어노테이션 없이는 실행 순서가 보장되지 않으며, 메서드명과 무관하게 무작위로 실행됩니다. 순서 보장이 필요하다면 반드시 @Order를 명시해야 합니다.

 

예외 처리: 한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장되지만, @Async 없이는 동기 방식으로 순차 실행되므로 중간 리스너의 지연이나 블로킹이 후속 리스너들의 실행 시간에 직접적인 영향을 미칩니다.

 

성능 영향: 비동기 처리(@Async)가 없는 경우 모든 리스너가 하나의 스레드에서 순차 실행되므로, 특정 리스너의 긴 처리 시간이 전체 이벤트 처리 시간을 증가시키고 후속 리스너들의 실행을 지연시킵니다.

가장 중요한 설계 원칙

@Order를 사용하여 실행 순서를 명시적으로 지정할 수 있지만, 근본적으로는 각 이벤트 리스너가 서로 영향을 미치지 않도록 설계하는 것이 가장 좋습니다. 리스너들이 독립적으로 동작하도록 설계하면 실행 순서나 한 리스너의 실패가 다른 리스너에게 영향을 주지 않아 더 안정적이고 확장 가능한 시스템을 구축할 수 있습니다. 순서 의존성이 필요한 경우에만 @Order를 사용하고, 가능한 한 리스너 간 결합도를 낮추는 것이 이상적입니다.


2. 들어가며

Spring에서 이벤트 기반 아키텍처를 구현할 때 @TransactionalEventListener를 사용하면 트랜잭션 커밋 후에 이벤트를 처리할 수 있습니다. 하지만 여러 개의 리스너가 하나의 이벤트를 구독할 때, 의도하지 않은 실행 순서나 예외 처리 문제를 만날 수 있습니다.

특히 @Async 없이 동기 방식으로 여러 리스너를 실행할 때 다음과 같은 의문이 생깁니다:

  • 리스너들이 순차적으로 실행되는가?
  • 하나의 리스너가 실패했을 때 다른 리스너들이 영향을 받는가?

이는 Spring의 이벤트 발행 메커니즘과 리스너 실행 순서 결정 방식과 관련이 있습니다. 이 글에서는 실제 테스트를 통해 이러한 문제들의 원인과 해결 방법을 살펴보겠습니다.

2. 문제 상황

다음과 같이 하나의 이벤트를 여러 리스너가 구독하는 상황을 가정해봅시다

public class EventTest {
    @TransactionalEventListener(Event.class)
    public void handleA(Event event) {
        log.info("handleA called!");
    }

    @TransactionalEventListener(Event.class)
    public void handleB(Event event) {
        log.info("handleB called!");
    }

    @TransactionalEventListener(Event.class)
    public void handleC(Event event) {
        log.info("handleC called!");
    }
}

이 코드를 실행하면 다음과 같은 결과가 나타납니다

실행 결과 (첫 번째 실행)

handleA called!
handleB called!
handleC called!

실행 결과 (두 번째 실행)

handleC called!
handleA called!
handleB called!

매번 실행할 때마다 리스너들의 실행 순서가 달라집니다. 순서가 일정하지 않습니다.

3. 원인 분석

3-1. Spring Event 처리 메커니즘

Spring은 ApplicationEventPublisher를 통해 이벤트를 발행하고, 등록된 모든 리스너에게 이벤트를 전달합니다. @TransactionalEventListener의 경우 기본적으로 트랜잭션 커밋 이후(AFTER_COMMIT)에 실행됩니다.

일반적인 경우의 정상 동작 흐름은 다음과 같습니다

1. ApplicationEventPublisher.publishEvent(event) 호출
2. 트랜잭션 커밋 완료 대기
3. 등록된 모든 @TransactionalEventListener 메서드 실행

3-2. 리스너 실행 순서 결정 메커니즘

Spring은 리스너들을 내부적으로 Set 또는 리플렉션 기반으로 수집합니다. 명시적인 순서 지정이 없으면 다음 요소들에 의해 순서가 결정됩니다

  • JVM의 리플렉션 API가 반환하는 메서드 순서 (비결정적)
  • 클래스 로딩 순서
  • 메서드의 메모리 상 위치

이러한 요소들은 실행 환경, JVM 버전, 클래스 로더에 따라 달라질 수 있어 순서가 보장되지 않습니다.

3-3. 문제 발생 시나리오

메서드명을 변경해도 순서는 여전히 무작위입니다

@TransactionalEventListener(Event.class)
public void handleBBBB(Event event) {  // 메서드명 변경
    log.info("handleBBBB called!");
}

실행 결과

# 첫 번째 실행
handleC called!
handleA called!
handleBBBB called!

# 두 번째 실행
handleC called!
handleBBBB called!
handleA called!

메서드명에 따라 순서가 정해지는 것은 아닙니다.

3-4. 동기 실행 방식의 영향

@Async가 없는 경우 모든 리스너는 하나의 스레드에서 순차적으로 실행됩니다

트랜잭션 커밋 → 리스너A 실행 → 리스너B 실행 → 리스너C 실행
(모두 같은 스레드에서 순차 실행)

다음과 같은 순서로 문제가 발생합니다

  1. 첫 번째 리스너 실행 시작
  2. 첫 번째 리스너가 완료될 때까지 대기
  3. 두 번째 리스너 실행 시작
  4. 두 번째 리스너가 완료될 때까지 대기
  5. 세 번째 리스너 실행 (이 지점에서 앞선 리스너들의 지연이 누적됨)

4. 해결 방법

방법 1: @Order 어노테이션 사용 (가장 권장)

가장 권장되는 방법은 @Order 어노테이션으로 명시적인 실행 순서를 지정하는 것입니다.

import org.springframework.core.annotation.Order;

public class EventTest {
    @TransactionalEventListener(Event.class)
    @Order(1)  // 첫 번째로 실행
    public void handleA(Event event) {
        log.info("handleA called!");
    }

    @TransactionalEventListener(Event.class)
    @Order(2)  // 두 번째로 실행
    public void handleB(Event event) {
        log.info("handleB called!");
    }

    @TransactionalEventListener(Event.class)
    @Order(3)  // 세 번째로 실행
    public void handleC(Event event) {
        log.info("handleC called!");
    }
}

실행 결과

# 1
handleA called!
handleB called!
handleC called!

# 2
handleA called!
handleB called!
handleC called!

# 3
handleA called!
handleB called!
handleC called!

모든 실행에서 동일한 순서가 보장됩니다.

이 방법이 좋은 이유

  • 명확하고 예측 가능한 실행 순서 보장
  • 코드만 보고도 실행 순서를 쉽게 파악 가능
  • 추가 설정 불필요

주의사항

  • 숫자가 작을수록 먼저 실행됩니다
  • 같은 Order 값을 가진 리스너들 간의 순서는 보장되지 않습니다

방법 2: 별도의 클래스로 분리

각 리스너를 별도의 클래스로 분리하고 @Order를 클래스 레벨에 적용할 수 있습니다.

@Component
@Order(1)
public class EventHandlerA {
    @TransactionalEventListener(Event.class)
    public void handle(Event event) {
        log.info("handleA called!");
    }
}

@Component
@Order(2)
public class EventHandlerB {
    @TransactionalEventListener(Event.class)
    public void handle(Event event) {
        log.info("handleB called!");
    }
}

@Component
@Order(3)
public class EventHandlerC {
    @TransactionalEventListener(Event.class)
    public void handle(Event event) {
        log.info("handleC called!");
    }
}

이 방법의 제약사항

  • 클래스가 많아져 관리 포인트가 증가
  • 간단한 리스너에는 과도한 구조

방법 3: 순서가 중요하지 않은 경우 명시적 문서화

순서가 정말 중요하지 않다면, 주석으로 명확히 표시합니다.

public class EventTest {
    // 이 리스너들은 실행 순서가 보장되지 않으며, 순서와 무관하게 동작합니다

    @TransactionalEventListener(Event.class)
    public void handleA(Event event) {
        log.info("handleA called!");
    }

    @TransactionalEventListener(Event.class)
    public void handleB(Event event) {
        log.info("handleB called!");
    }
}

언제 이 방법을 사용하면 좋은지

  • 각 리스너가 완전히 독립적인 작업을 수행
  • 리스너 간 의존성이 전혀 없는 경우
  • 예: 독립적인 로깅, 메트릭 수집

5. 예외 처리 동작

여러 리스너 중 하나가 실패했을 때의 동작을 살펴봅시다.

중간 리스너에서 예외 발생

public class EventTest {
    @TransactionalEventListener(Event.class)
    @Order(1)
    public void handleA(Event event) {
        log.info("handleA called!");
    }

    @TransactionalEventListener(Event.class)
    @Order(2)
    public void handleB(Event event) {
        log.info("handleB called!");
        throw new RuntimeException("Error!");  // 이 지점에서 예외 발생
    }

    @TransactionalEventListener(Event.class)
    @Order(3)
    public void handleC(Event event) {
        log.info("handleC called!");
    }
}

실행 결과

# 1
handleA called!
handleB called!
>> Exception Error!
handleC called!

# 2
handleA called!
handleB called!
>> Exception Error!
handleC called!

한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장됩니다.

중간 리스너가 블록(Block)되는 경우

@Async가 없는 상태이기 때문에 하나의 스레드로 호출되고 있으니, 중간 리스너가 오래 걸리는 작업을 수행하는 경우 이후 리스너들의 실행이 지연됩니다.

public class EventTest {
    @TransactionalEventListener(Event.class)
    @Order(1)
    public void handleA(Event event) {
        log.info("handleA called!");
    }

    @TransactionalEventListener(Event.class)
    @Order(2)
    public void handleB(Event event) {
        log.info("handleB called!");
        Thread.sleep(3_000);  // 3초 대기
    }

    @TransactionalEventListener(Event.class)
    @Order(3)
    public void handleC(Event event) {
        log.info("handleC called!");
    }
}

실행 결과

# 1
15:01:14.883 handleA called!
15:01:14.883 handleB called!
15:01:17.883 handleC called!

# 2
15:01:17.917 handleA called!
15:01:17.917 handleB called!
15:01:20.917 handleC called!

중간 리스너의 처리 시간이 길어지면 이후에 있는 리스너에 영향을 미칩니다.

6. 정리

Spring의 @TransactionalEventListener에서 하나의 이벤트를 여러 개의 리스너가 처리할 때는 실행 순서와 예외 처리 메커니즘을 이해하는 것이 중요합니다.

 

핵심 내용 요약:

 

실행 순서: @Order 어노테이션 없이는 실행 순서가 보장되지 않으며, 메서드명과 무관하게 무작위로 실행됩니다. 순서 보장이 필요하다면 반드시 @Order를 명시해야 합니다.

 

예외 처리: 한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장되지만, @Async 없이는 동기 방식으로 순차 실행되므로 중간 리스너의 지연이나 블로킹이 후속 리스너들의 실행 시간에 직접적인 영향을 미칩니다.

 

성능 영향: 비동기 처리(@Async)가 없는 경우 모든 리스너가 하나의 스레드에서 순차 실행되므로, 특정 리스너의 긴 처리 시간이 전체 이벤트 처리 시간을 증가시키고 후속 리스너들의 실행을 지연시킵니다.

8. 참고 자료

반응형
반응형

들어가며

Spring Boot로 파일 업로드 기능을 만들다 보면 비동기 이벤트 사용할 때 예상치 못한 에러를 만나게 되는 경우가 있다. 특히 @AsyncApplicationEventPublisher를 같이 쓸 때 MultipartFile 관련 예외가 발생하는데, 이게 사실 톰캣의 임시 파일 관리 방식 때문이었다. 이 글에서는 이 문제가 왜 생기는지, 그리고 어떻게 해결하는지 알아보려고 한다.

문제 상황

파일 업로드 API에서 이벤트를 발행하고 비동기로 처리하는 코드를 작성했는데 다음과 같은 에러가 간헐적으로 발생했다.

java.io.FileNotFoundException: /tmp/tomcat.xxx/work/Tomcat/localhost/ROOT/upload_xxx.tmp (No such file or directory)

파일 처리 로직을 비동기로 분리하려고 할 때 특히 이런 문제가 자주 생긴다.

원인 분석

Tomcat의 MultipartFile 처리 방식

Spring에서 MultipartFile로 전달되는 파일은 실제로는 톰캣의 임시 디렉토리에 저장된다. 보통 이런 경로에 임시 파일이 생성된다.

/tmp/tomcat.{포트번호}/work/Tomcat/localhost/ROOT/upload_{랜덤값}.tmp

임시 파일의 생명주기

임시 파일은 HTTP 요청이 완료되면 자동으로 삭제된다. 톰캣은 요청 처리가 끝나면 cleanup 작업으로 생성된 임시 파일들을 제거해버린다.

비동기 처리와의 충돌

@Async 어노테이션을 사용한 비동기 이벤트 처리에서 문제가 생기는 시나리오는 이렇다.

  1. 클라이언트가 파일 업로드 요청을 보냄
  2. 컨트롤러에서 요청을 받고 이벤트를 발행
  3. HTTP 응답이 클라이언트에게 즉시 반환됨 (요청 완료)
  4. 톰캣이 임시 파일을 삭제함
  5. 비동기 스레드가 이벤트를 처리하려고 시도
  6. 이미 삭제된 임시 파일에 접근하려다 예외 발생
@RestController
public class FileController {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @PostMapping("/upload")
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
        // 이벤트 발행
        eventPublisher.publishEvent(new FileUploadEvent(file));

        // 즉시 응답 반환 -> 요청 완료 -> 임시 파일 삭제 예약
        return ResponseEntity.ok("Upload started");
    }
}

@Component
public class FileEventListener {

    @Async
    @EventListener
    public void handleFileUpload(FileUploadEvent event) {
        // 이 시점에 이미 임시 파일이 삭제되었을 수 있음
        MultipartFile file = event.getFile();
        file.getInputStream(); // FileNotFoundException 발생!
    }
}

해결 방법

1. 이벤트 발행 전에 파일을 영구 저장소에 저장

가장 추천하는 방법이다. 비동기 처리 전에 파일을 영구 저장소에 먼저 저장하고, 파일 경로만 이벤트로 전달하는 방식이다.

@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
    // 1. 파일을 영구 저장소에 저장
    String savedFilePath = fileStorageService.save(file);

    // 2. 파일 경로만 이벤트로 전달
    eventPublisher.publishEvent(new FileUploadEvent(savedFilePath));

    return ResponseEntity.ok("Upload started");
}

@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) {
    // 저장된 파일 경로를 사용
    String filePath = event.getFilePath();
    File file = new File(filePath);
    // 파일 처리 로직
}

2. MultipartFile의 바이트 배열을 미리 추출

파일 크기가 그렇게 크지 않다면 바이트 배열로 변환해서 전달할 수도 있다.

@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
    // 파일 내용을 바이트 배열로 추출
    byte[] fileBytes = file.getBytes();
    String fileName = file.getOriginalFilename();

    eventPublisher.publishEvent(new FileUploadEvent(fileBytes, fileName));

    return ResponseEntity.ok("Upload started");
}

@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) {
    byte[] fileBytes = event.getFileBytes();
    String fileName = event.getFileName();
    // 바이트 배열을 사용한 처리
}

3. 동기 방식으로 처리 후 결과만 비동기로 전송

파일 처리는 동기로 수행하고, 후속 작업(알림 전송 등)만 비동기로 처리하는 방법도 괜찮다.

@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
    // 동기로 파일 처리
    FileProcessResult result = fileService.processFile(file);

    // 처리 결과만 비동기로 전달
    eventPublisher.publishEvent(new FileProcessedEvent(result));

    return ResponseEntity.ok("Upload completed");
}

4. @TransactionalEventListener 사용 고려

트랜잭션이랑 연계된 작업이라면 @TransactionalEventListener를 사용해서 커밋 전에 파일을 처리할 수 있다.

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleFileUpload(FileUploadEvent event) {
    // 트랜잭션 커밋 전에 실행되므로 요청이 아직 완료되지 않음
    MultipartFile file = event.getFile();
    // 파일 처리
}

다만 이 방법은 @Async랑 같이 쓸 수 없어서 동기 처리만 가능하다.

가장 좋은 방법

  1. 컨트롤러 레이어: 파일을 검증하고 임시 저장소 또는 영구 저장소에 저장
  2. 서비스 레이어: 파일 경로 또는 식별자를 DB에 저장
  3. 이벤트 발행: 파일 경로/식별자만 포함된 이벤트 발행
  4. 비동기 처리: 저장된 파일에 접근해서 추가 처리(썸네일 생성, 바이러스 검사 등)
@Service
@Transactional
public class FileUploadService {

    public FileUploadResult uploadFile(MultipartFile file) throws IOException {
        // 1. 파일 검증
        validateFile(file);

        // 2. 영구 저장소에 저장
        String storedPath = storageService.store(file);

        // 3. DB에 메타데이터 저장
        FileMetadata metadata = fileRepository.save(
            new FileMetadata(file.getOriginalFilename(), storedPath)
        );

        // 4. 이벤트 발행 (파일 ID만 전달)
        eventPublisher.publishEvent(new FileStoredEvent(metadata.getId()));

        return new FileUploadResult(metadata.getId(), storedPath);
    }
}

@Component
public class FileEventListener {

    @Async
    @EventListener
    public void handleFileStored(FileStoredEvent event) {
        // 저장된 파일 정보를 조회
        FileMetadata metadata = fileRepository.findById(event.getFileId())
            .orElseThrow();

        // 실제 파일에 접근
        File file = new File(metadata.getStoredPath());

        // 비동기 후처리 (썸네일, 바이러스 검사 등)
        postProcessFile(file);
    }
}

정리

Spring에서 MultipartFile을 비동기로 처리할 때는 톰캣의 임시 파일 관리 메커니즘을 이해하는 게 정말 중요하다. HTTP 요청이 완료되면 임시 파일이 삭제되기 때문에, 비동기 처리 전에 반드시 파일을 영구 저장소에 저장하거나 바이트 배열로 변환해야 한다.

가장 안전하고 확장 가능한 방법은 파일을 먼저 저장하고 파일 경로나 식별자만 이벤트로 전달하는 것이다. 이렇게 하면 파일 삭제 문제를 방지할 수 있을 뿐만 아니라, 재시도 로직이나 장애 복구 측면에서도 훨씬 유리하다.

반응형
반응형

코드를 작성하다 보면 가끔씩 스페이스를 두번 누를때가 있는데 이때 마다 점(.)이 찍히는 상황이 발생하곤 한다.
이럴때 다음과 같이 설정을 변경해주면 자동으로 점이 찍히는 기능을 비활성화 할수 있다.
 
MAC 버전은 Ventura 이후 버전을 사용하고 있다

설정 -> 키보드 -> 텍스트 입력 -> 입력 소스 [편집]을 클릭!

"스페이스를 두번 눌러 마침표 추가"를 비활성화 해주면 해결됩니다.

 입력관련 기능들은 여기에 모여있으니 참고하시면 될것 같습니다.

반응형