티스토리 뷰

반응형

목차

    배경

    Java에서는 다음과 같이 요청 클래스가 정의된 경우 null을 할당하며 필수 파라미터의 누락 여부에 따른 컨트롤러 단위 테스트를 작성할 수 있다.

    public class User {
    	private String id;
        private String name;
    }
    
    // 필수 파라미터 누락
    User user = new User(null, "사용자명");

    하지만 Kotlin에서는 NPE를 방지하기 위해 null을 허용하지 않는 null safety 타입을 제공하는데, 이 경우 필수 파라미터가 누락된 객체 생성 자체가 불가능하다.

    class User(
        val id: String,
        val name: String,
    )
    
    // 객체 생성 자체가 불가
    val user = User(null, "사용자명")

    따라서 필수 파라미터 누락 여부에 따른 케이스에 대한 테스트를 작성하려면 Json 문자열을 직접 작성해야한다. 하지만 매번 유사한 형태의 Json 문자열을 반복 작성하는 것은 개발 피로도를 높인다. 또한 유사한 형태의 파편화된 중복 코드는 코드 가독성을 해치고 유지보수성을 저하할 것이다.

     

    또는 요청 클래스의 타입을 nullable로 수정하는 방안이 있다. 하지만 NPE를 방지하기 위해 null safety 타입을 제공하는 코틀린의 설계 원칙에 빗대어 볼 때 null로 들어오면 안되는 값에 대해 nullable하게 정의하는 것은 서로 상충된다.

    class User(
        val id: String?, // null이면 안되는 값을 nullable하게 정의하는게 적절한가?
        val name: String,
    )

    사이드 프로젝트를 진행하며 이를 해소할 방안을 고민해보았고, Jackson 라이브러리의 ObjectNode와 Builder 패턴을 활용했다. 이번 글에서는 Builder 패턴을 활용한 일종의 Json 객체 생성을 위한 테스트 픽스쳐를 제공하여, 유연한 컨트롤러 단위 테스트 작성 방안을 소개한다.

    @WebMvcTest

    Builder 패턴 활용과 작성 방안에 들어가기에 앞서, 이번 글에서 예시 코드는 Kotlin과 Spring Boot 3.4.4를 기반으로 작성된다. Spring 진영에서는 컨트롤러 레이어의 가벼운 단위 테스트 작성을 지원하는 @WebMvcTest 애너테이션을 제공한다. 이는 Web Layer와 관련된 클래스만 스프링 빈(Spring Bean)으로 등록한다. @WebMvcTest는 웹 계층 관련 빈Spring MVC 인프라 빈을 스프링 컨테이너에 등록하며 이때 등록 대상 빈은 다음과 같다.

     

    웹 계층 관련 빈

     

    • @Controller, @RestController가 붙은 컨트롤러 클래스 중 지정한 컨트롤러
      (예를 들어@WebMvcTest(OrderCommandController::class)은 OrderCommandController 컨트롤러만 빈으로 등록된다.)
    • @ControllerAdvice, @RestControllerAdvice - 전역 예외 처리기
    • @JsonComponent - JSON 직렬화/역직렬화 커스터마이징
    • Converter, GenericConverter, Formatter - 데이터 변환기들

    Spring MVC 인프라 빈

     

    • MockMvc - 웹 계층 테스트를 위한 목 객체
    • HandlerMapping, HandlerAdapter - 요청 처리 매핑
    • ViewResolver - 뷰 해석기
    • MessageSource - 국제화 메시지 소스
    • WebMvcConfigurer 구현체들

    ObjectMapper

    Spring Boot 1.0부터는 Java 객체와 Json 문자열간 직렬화/역직렬화를 지원하는 Jackson 라이브러리를 기본으로 탑재하고 있다. ObjectMapper는 Java 객체와 JSON 문자열간 변환을 담당하는데, JSON 노드를 생성하는 createObjectNode(), 직렬화/역직렬화를 제공하는 writeValueAsString() 등을 제공한다. ObjectMapper가 제공하는 메서드를 간략히 살펴보면 다음과 같다.

     

    createObjectNode()

    빈 ObjectNode를 생성한다. Jackson 라이브러리를 활용하면 Json 문자열을 트리 형태의 Java 객체 구조로 나타내는데, 이때 ObjectNode가 노드 역할을 한다. (정확히 말하면 JsonNode가 트리의 노드 타입을 정의하고 ObjectNode가 이를 구현한다.)

    val objectNode = om.createObjectNode()
    objectNode.put("name", "홍길동")
    objectNode.put("age", 30)
    
    // 결과: {"name":"홍길동","age":30}

     

    createArrayNode()

    빈 ArrayNode를 생성한다. ArrayNode로 Json 문자열에서 배열 구조를 나타낼 수 있다.

    val arrayNode = om.createArrayNode()
    arrayNode.add("apple")
    arrayNode.add("banana")
    
    // 결과: ["apple","banana"]

     

    writeValueAsString(value: Any)

    Java 트리 객체를 Json 문자열로 직렬화(Serialization)한다.

    data class Person(val name: String, val age: Int)
    val person = Person("김철수", 25)
    val json = om.writeValueAsString(person)
    
    // 결과: {"name":"김철수","age":25}

     

    readValue(content: String, valueType: Class<T>)

    Json 형태로 작성된 문자열을 주어진 객체(T)로 역직렬화(Deserialization)한다.

    val json = """{"name":"이영희","age":28}"""
    val person = om.readValue(json, Person::class.java)

     

    readTree(content: String)

    Json 형태로 작성된 문자열을 JsonNode 트리 구조로 파싱한다.

    val json = """{"users":[{"name":"A"},{"name":"B"}]}"""
    val tree = om.readTree(json)
    val firstUser = tree["users"][0]["name"].asText() // "A"

    JsonNode

    Json 형태의 문자열은 ObjectMapper를 통해 트리 구조의 Java 객체로 직렬화/역직렬화를 수행하는데, 이때 각 트리의 노드는 JsonNode 타입으로 정의된다. 다만 JsonNode는 추상 클래스이며, 모든 Json 노드 타입의 부모 역할을 한다. 실질적으로는 JsonNode를 구현하는 ObjectNode, ArrayNode 등을 사용한다.

     

     

    JsonNode를 구현하는 하위 클래스로 ObjeectNode, ArrayNode 등이 있다.

     

    • ObjectNode : JSON 객체({})에 매핑 
    • ArrayNode : JSON 배열([])에 매핑
    • TextNode : 문자열에 매핑
    • NumericNode : 숫자에 매핑
    • BooleanNode : Boolean에 매핑

    ObjectNode

    Json 형식의 문자열을 Java 객체로 변환할 때 트리를 구성하는 하나의 노드 역할을 한다.

    private val om: ObjectMapper = jacksonObjectMapper()
    
    // 루트 JSON 객체 노드 생성
    private val root: ObjectNode = om.createObjectNode()

    주요 메서드로 put(key, value), set<T>(key, node), remove(key), get(key)가 있다. 각 메서드별 동작은 다음과 같다.

     

    • put(key, value) : 기본 데이터 타입(String, int, boolean 등) 값을 키-값 쌍으로 추가
    • set<T>(key, node) : JsonNode 타입의 객체(ObjectNode, ArrayNode 등)를 키-값 쌍으로 추가  
    • remove(key) : 키를 기준으로 해당하는 전체 값을 제거
    • get(key) : 특정 키에 해당하는 값을 JsonNode로 조회

    ArrayNode

    Json 형식의 문자열을 Java 객체로 변환할 때 Json 배열([])에 해당한다.

    private val om: ObjectMapper = jacksonObjectMapper()
    
    // 빈 배열 [] 생성
    val arr: ArrayNode = om.createArrayNode()

    주요 메서드로 addObject(), add(value), get(intdex), size()가 있다. 각 메서드별 동작은 다음과 같다.

     

    • addObject() : 배열에 새로운 빈 객체 {} 추가하고 ObjectNode 반환
    • add(value) : 배열에 기본 타입 값 추가
    • get(index) : 특정 인덱스의 요소 조회
    • size() : 배열 크기 반환

    Jackson 라이브러리와 Builder 패턴을 활용한 유연한 컨트롤러 단위 테스트 작성

    Java 객체와 Json 문자열간 매핑을 위해 사용되는 ObjectMapper와 JsonNode(ObjectNode, ArrayNode)와 제공하는 메서드를 간략히 살펴보았다. 이를 Builder 패턴과 함게 활용하여 코틀린에서의 null-safety 타입을 사용하면서 요청 파라미터 생성을 위한 Json 문자열을 유연하게 작성할 수 있다.

     

    예를 들어 주문서 생성 요청 컨트롤러의 단위 테스트를 위해 다음과 같이 주문자 정보를 담는 가장 기본적인 Json 문자열을 생성해야하는 경우를 가정해본다.

    {
      "customer": {
        "name": "홍길동",
        "recipient": "이순신",
        "phone": "010-1234-5678",
        "address": "서울시 강남구 테헤란로 123",
        "detailAddress": null,
        "zipCode": "01234"
      }
    }

    먼저 createObjectNode()를 통해 Json 트리 구조의 root 노드를 생성한다.

    private val root: ObjectNode = om.createObjectNode()

    위 Json 문자열로 표현된 주문자 정보를 ObjectNode로 나태낸다면 다음과 같이 작성할 수 있다. ObjectMapper의 createObjectNode()를 호출하여 비어있는 ObjectNode를 생성하고 동일한 객체에 대해 Kotlin의 apply 스코프 함수(Scope Function)을 호출하여 값을 초기화하고 반환한다. 값을 초기화할때는 put(key, value) 메서드를 호출하여 지정한다.

    val customer = om.createObjectNode().apply {
    	put("name", "홍길동")
    	put("recipient", "이순신")
    	put("phone", "010-1234-5678")
    	put("address", "서울시 강남구 테헤란로 123")
    	put("detailAddress", null as String?)
    	put("zipCode", "01234")
    }

    생성된 customer Object Node에 key를 지정하여 앞서 생성한 root 노드의 하위 노드로 추가한다. 이때는 set<T>(key, value) 메서드를 통해 노드간 계층 구조를 표현한다.

    root.set<ObjectNode>("customer", customer)
    
    // 또는 아래와 같이 한 번에 작성할 수 있다.
    root.set<ObjectNode>(
    	"customer",
    	om.createObjectNode().apply {
    		put("name", "홍길동")
    		put("recipient", "이순신")
    		put("phone", "010-1234-5678")
    		put("address", "서울시 강남구 테헤란로 123")
    		put("detailAddress", null as String?)
    		put("zipCode", "01234")
    	},
    )

    전체 코드는 다음과 같다. Builder 패턴을 활용해 메서드 체이닝 형태로 Json 요청 파라미터를 작성하는 것이 목적이므로, root 노드에 customer Object Node를 연결한 뒤, JsonOrderRequestBuilder 객체를 그대로 반환한다. 이때도 코틀린의 apply 스코프 함수를 활용하였다.

    class JsonOrderRequestBuilder {
        // ObjectMapper : Java 객체와 JSON간 변환 담당
        private val om: ObjectMapper = jacksonObjectMapper()
    
    	// root JSON 객체 노드 생성
        private val root: ObjectNode = om.createObjectNode()
    
        // 필수 customer 객체 생성
        fun withDefaultCustomer() =
            apply {
                root.set<ObjectNode>(
                    "customer",
                    om.createObjectNode().apply {
                        put("name", "홍길동")
                        put("recipient", "이순신")
                        put("phone", "010-1234-5678")
                        put("address", "서울시 강남구 테헤란로 123")
                        put("detailAddress", null as String?)
                        put("zipCode", "01234")
                    },
                )
            }
    }

    사용처에서는 다음과 같이 호출하여 Json 형태에 매핑되는 트리 구조의 객체를 생성할 수 있다.

    val json = 
    	JsonBuilder()
    		.withDefaultCustomer()
    		.build()

    단위 테스트 작성을 위해서는 특정 파라미터의 누락 여부와 누락 여부에 따른 동작을 테스트해야한다. 이 경우 ObjectNode의 remove() 메서드를 활용하여 특정 파라미터를 제거하며 테스트할 수 있다. ObjectNode의 특정 키 기준으로 값을 제거하는 코드는 다음과 같이 작성할 수 있다.

    // 개발자가 지정한 key가 JSON 구조에 있는지 확인하고 있다면 제거
    fun withoutCustomerField(fieldName: String) =
        apply {
            (root.get("customer") as? ObjectNode)?.remove(fieldName)
        }
        
    // 호출 예시
    val json = 
    	JsonBuilder()
    		.withDefaultCustomer()
    		.withoutCustomerField("name")
    		.build()
            
    // 매핑되는 Json 문자열
    {
      "customer": {
        "recipient": "이순신",
        "phone": "010-1234-5678",
        "address": "서울시 강남구 테헤란로 123",
        "detailAddress": null,
        "zipCode": "01234"
      }
    }

    Json 배열 생성이 필요한 경우 ArrayNode를 활용할 수 있다. 이 경우 ObjectMapper의 createArrayNode()를 호출하여 ArrayNode를 생성하고 배열의 원소로 ObjectNode를 할당한다. 마찬가지로 apply 스코프 함수를 사용하여 간략하게 작성했다.

    class JsonOrderRequestBuilder {
        // ObjectMapper : Java 객체와 JSON간 변환 담당
        private val om: ObjectMapper = jacksonObjectMapper()
    
    	// root JSON 객체 노드 생성
        private val root: ObjectNode = om.createObjectNode()
    
        fun withDefaultItems() =
            apply {
                // ArrayNode 생성
                val arr: ArrayNode = om.createArrayNode()
    
                // addObject(): 배열에 새 객체 {} 추가하고 ObjectNode 반환
                arr.addObject().apply {
                    // 반환된 ObjectNode에 필드 추가
                    put("id", 1L)
                    put("name", "제주 노지 감귤")
                    put("price", 12000)
                    put("quantity", 2)
                }
                arr.addObject().apply {
                    put("id", 2L)
                    put("name", "제주 하우스 감귤")
                    put("price", 15000)
                    put("quantity", 1)
                }
                root.set<ArrayNode>("items", arr)
            }
    	}
    }

    다음과 같이 호출하여 사용할 수 있다.

    // 호출 예시
    val json = 
    	JsonBuilder()
    		.withDefaultItems()
    		.build()
            
    // 매핑되는 Json 문자열
    {
      "items": [              
        {                     
          "id": 1,
          "name": "제주 노지 감귤",
          "price": 12000,
          "quantity": 2
        },
        {                    
          "id": 2,
          "name": "제주 하우스 감귤", 
          "price": 15000,
          "quantity": 1
        }
      ]
    }

    Json 배열에서도 특정 파라미터의 누락 여부와 누락 여부에 따른 동작을 테스트해야한다. 또한 Json 배열로 요청 파라미터를 전달하는 경우 도중에 필수 값이 누락된 요청이 포함되어있는지에 따른 테스트도 진행해야할 것이다. 이때 ArrayNode에서 특정 인덱스에 해당하는 노드의 value를 제거하며 Json 객체를 생성할 수 있다.

    fun withoutItemFieldAt(
        index: Int,
        fieldName: String,
    ) = apply {
        (root.get("items") as? ArrayNode)
            ?.get(index)
            ?.let { it as? ObjectNode }
            ?.remove(fieldName)
    }
    
    // 호출 예시
    val json = 
    	JsonBuilder()
    		.withDefaultItems()
    		.withoutItemFieldAt(1, "name")
    		.build()
            
    // 매핑되는 Json 문자열
    {
      "items": [              
        {                     
          "id": 1,
          "name": "제주 노지 감귤",
          "price": 12000,
          "quantity": 2
        },
        {                    
          "id": 2,
          "price": 15000,
          "quantity": 1
        }
      ]
    }

     

    반응형
    Comments