Handling Null in URL

About

API를 구현하다보면 /posts?type=notice?type=notice, /posts/{id}, 에서 id처럼 URL을 통해 값을 전달하는 경우가 있다.

Spring MVC에서는 컨트롤러에서 요청으로 들어온 해당 값들을 각각 @RequestParam, @PathVariable어노테이션을 이용해 참조할 수 있다.

@RequestParam

Java

Spring을 자바로 작성할 때는 다음과 같이 진행할 수 있었다.

PostController.java
@GetMapping("/posts")
public ResponseEntity<List<PostResponse>> requestParam(
    @RequestParam(defaultValue = "post", required = false) String type) {

    return ResponseEntity.ok()
        .body(postSearchService.search(type));
}

type은 실제로 null이 들어올 수도 있다. required=false를 전달해서 필수값이 아님을 명시한다. (디폴트는 false)

defaultValue로 기본값을 전달할 수 있다. (""로 감싸줘야 한다.)

Kotlin

코틀린에서는 언어 차원에서 타입 뒤에 ?를 붙이는 것으로 nullable 타입과 non-nullable 타입을 구분하고 (완전히 다른 타입이 된다), 함수에서 디폴트 값을 지원한다. 디폴트 값은 이런 식으로 전달할 수 있다.

fun speak(sentence: String = "Bow wow!") {
    println(sentence)
}

sentence아무것도 전달되지 않으면 = 뒤의 디폴트 값이 들어가게 된다.

그렇다면 스프링에서도 이 기능을 응용해서 PostController 예제를 다음과 같이 작성하면 어떨까?

PostController.kt
@GetMapping("/posts")
fun requestParam(
    @RequestParam type: String? = "post"
): ResponseEntity<List<PostResponse>> {
    return ResponseEntity.ok()
        .body(postSearchService.search(type))
}

그러나 이렇게 하게 되면 실제로는 type에는 null이 들어간다.

Spring의 ArgumentResolver (RequestParamArgumentResolver)를 거치며, type에 애초에 null이 들어간 채로 시작하므로, 디폴트 값이 삽입되지 않는 것이다.

따라서 다음과 같이 진행하면 된다.

PostController.kt
@GetMapping("/posts")
fun requestParam(
    @RequestParam(defaultValue = "post", required = false) type: String?
): ResponseEntity<List<PostResponse>> {
    return ResponseEntity.ok()
        .body(postSearchService.search(type))
}

required = true

또 하나 짚고 넘어갈 부분이 있다. required 옵션에 관한 내용이다. requireddefaultValue 유무의 조합으로 4가지가 나오는데, 각 상황에서 오류를 일부러 발생시켜보자. (500이 나오면 원치 않는 상황이 되는거다!)

1번 상황 - defaultValue 없음, required=true, non-nullable

type의 디폴트 값을 없애고, 반드시 전달받도록 하고 싶다. required=true를 넣고 non-nullable 타입으로 하면 될까?

@RequestParam(required = true) type: String

일부러 오류를 발생시키기 위해 이렇게 디폴트값을 안넣고 요청해보자.

curl -i http://localhost:8080/posts

자, 이렇게 전달하면 500이 아닌 400 Bad Request가 반환된다.

✅ 바람직하다! 나머지 상황도 계속해서 확인해보자.

2번 상황 - defaultValue 없음, required=false, nullable

@RequestParam(required = false) type: String?

type을 전달하지 않으면 null이 들어가고, type을 전달하면 해당 값으로 초기화된다. 둘 다 오류가 발생하지 않는다.

✅ 바람직하다.

3번 상황 - defaultValue 있음, required=true, non-nullable

@RequestParam(defaultValue = "post", required = true) type: String

type을 전달하지 않으면 디폴트 값 "post"가 들어가고, type을 전달하면 해당 값으로 초기화된다. 둘 다 오류가 발생하지 않는다.

✅ 바람직하다.

4번 상황 - defaultValue 있음, required=false, nullable

@RequestParam(defaultValue = "post", required = false) type: String?

3번 상황과 동일하게 동작한다. 다만 컨트롤러에서 해당 값을 사용할 때 null이 들어가지 않았음에도 불구하고 null 체크를 해줘야 한다.

🫤 불편하다.

잘 되는 예제를 알아봤으니 이제 required와 nullable을 다르게 만 혼종 케이스를 알아보자. (궁금하니까!)

5번 상황 - defaultValue 없음, required=true, nullable

@RequestParam(required = true) type: String?

4번 상황과 동일하게 동작하며, 해당 값을 사용할 때 null이 들어가지 않았음에도 null 체크를 해줘야 한다.

근데 문제가 있다. required=true인데 type을 전달하지 않아도 오류가 발생하지 않는다.

필수값으로 마킹하기 위해 required=true로 설정한 것인데 필수값 체킹이 안된다니...

❌ 끔찍하다.

6번 상황 - defaultValue 없음, required=false, non-nullable

@RequestParam(required = false) type: String

type을 전달하면 문제 없이 동작한다. 그런데 전달하지 않았다면 다음과 같이 NullPointerException이 발생한다.

java.lang.NullPointerException: Parameter specified as non-null is null: method com.litsynp.kotring.interfaces.PostController.requestParam, parameter type

그리고 500 Error가 반환된다.

❌ 실수가 아니라면 이럴 일 없겠지만 당연히 이러면 안된다.

7번 상황 - defaultValue 있음, required=true, nullable

@RequestParam(defaultValue = "post", required = true) type: String?

5번과 동일하게 동작하며 문제도 같다고 할 수 있. 디폴트값도 있고 required=true인데 굳이 nullable 타입을 쓸 필요가 있을까?

🫤 바람직하지 않다.

8번 상황 - defaultValue 있음, required=false, non-nullable

@RequestParam(defaultValue = "post", required = false) type: String

defaultValue가 있고, required=false이다. type은 non-nullable이다.

🤔 의도한 대로는 동작한다...만 defaultValue만 빠뜨려도 6번이랑 똑같아진다. 주의해야겠다.

결론

defaultValue가 있는 경우엔 다음과 같이 하자.

  • required=true(또는 false - 오류는 안나지만 주의 필)

  • non-nullable (e.g., String)

defaultValue가 없는 경우엔 다음과 같이 하자.

  • required=false

  • nullable (e.g., String?)

@PathVariable

@PathVariable 에도 required 옵션으로 null checking을 진행할 수 있다.

Java

보통 @PathVariable은 다음과 같이 사용한다.

@GetMapping("/posts/{id}")
public ResponseEntity<PostResponse> pathVariable(
    @PathVariable String id) {

    return ResponseEntity.ok()
        .body(postService.findById(id));
}

사실 위의 예제를 활용해도 @PathVariable을 optional하게 사용하긴 어렵다. /posts로 요청하든, /posts/로 요청하든, 해당 URL로 매핑된 컨트롤러를 찾지 못해 404 Not Found가 반환될 것이다.

@PathVariablerequired 옵션을 억지로 넣어보자면 다음과 같다.

PostController.java
@GetMapping("/posts/{id}", "/posts/{id}/detail")
public ResponseEntity<*> optionalPathVariable(
    @PathVariable(required = false) String id) {

    if (id) {
        return ResponseEntity.ok()
            .body(postService.findById(id));
    }

    return ResponseEntity.ok()
        .body(postService.findAll());
}

@PathVariable의 경우 defaultValue는 지원하지 않는다.

Kotlin

Kotlin에서는 다음과 같이 할 수 있겠다.

PostController.kt
@GetMapping("/posts/{id}", "/posts/{id}/detail")
fun optionalPathVariable(
    @PathVariable(required = false) id: String?
): ResponseEntity<*> {
    return id?.let {
        ResponseEntity.ok()
            .body(postService.findById(id));
    } ?: ResponseEntity.ok()
        .body(postService.findAll());
}

참고로 required=true, non-nullable 타입으로 바꿨을 때 당연히 id를 전달하지 않으면 id를 전달하지 않는 경우의 코드를 실행시킬 수 없으므로 의미가 없을 뿐 아니라 500 Error를 반환한다.

이런 경우엔 그냥 따로 컨트롤러 메소드를 하나 만들자.

More on the matter

REF

Last updated