String을 고집하는 Controller 개선기

Palbok Yu (Lifeclue)
9 min readJan 13, 2019

--

오랜만에 글을 적으려니 어떤 주제를 적어야 할지 고민이 많이 되었습니다. 그러다 문득 주제를 정해서 이야기를 만들어내기 보다는 그냥 겪었던 이야기를 풀어내는 것이 좋겠다는 생각을 하게 됐습니다. 이번에는 요청 인자를 유독 String으로 받도록 구현된 Spring Controller에 대해 이야기해 보려 합니다.

고객 중심 Controller

제가 요즘 진행하는 프로젝트는 몇몇 모듈로 구성이 되어 있는데 API 모듈, 그러니까 이 프로젝트의 인터페이스가 되는 모듈을 최근 자주 손보고 있습니다. 특히 요즘은 컨트롤러쪽에 관심을 많이 두고 있는데요. 이 모듈의 컨트롤러는 요청을 String으로 받아 enum으로 변환하는 과정을 코드로 직접 짜둔 일이 유독 많았습니다. 사실 프로젝트를 처음 시작할 때부터 API를 개발한 동료와 함께 했기 때문에 그의 의도는 잘 알고 있었습니다.

고객이 대소문자를 구분하지 않아도 서버가 알아서 서비스를 잘 제공하도록 하겠다.

그의 의도는 순수했지만 API가 추가될 때마다 컨트롤러의 메서드에는 String을 enum으로 고쳐쓰는 코드가 늘어갔습니다.

@GetMapping("/users/{userNo}/transactions")
public Something getTransactions(@PathVariable long userNo, @RequestParam String txType) {
TxType txTypeEnum = TxType.valueOf(txType.toUpperCase());
// ...
}

제가 요즘 이 코드를 보고서 든 생각은 ‘고객이 대소문자를 구분하여 요청하는 것이 그리 어려운 일은 아닐 것'이라는 겁니다.

String을 eum으로

사실 스프링은 받고자 하는 요청 인자의 자료형에 따라 대부분 알아서 바인딩을 해주기 때문에 요청 인자를 enum으로 받는 것은 그리 어려운 일이 아닙니다.

@GetMapping("/users/{userNo}/transactions")
public Something getTransactions(@PathVariable long userNo, @RequestParam TxType txType) {
// ...
}

인자를 String에서 enum형으로 변경만 해주면 되겠죠. 하지만 서버의 입장에서 이미 대소문자를 구분하지 않아도 되도록 제공하고 있던 API를 갑자기 아무런 공지나 소통 없이 대문자로 강제해 버릴 수는 없었습니다.

여기서 고민이 시작됩니다.

‘고객에게 여전히 대소문자 구분 없이 API를 제공하고 싶다. 하지만 나를 포함한 개발자들은 enum 자료형을 쓰고 싶다.’

두 가지 요구사항을 만족시킬 수 있는 방법이 있습니다.

Converter

스프링은 바인딩할 값을 변환할 수 있게 해주는 기능을 제공합니다. org.springframework.core.convert.converter.Converter<S, T>인터페이스를 구현하고 스프링에 이를 쓰겠다고 알려주면 스프링이 컨트롤러에 값을 바인딩할 때 이 Converter를 한번 거치게 되는 것이죠. 우리는 String이 아니라, 예를 들어 TxType이라는 enum 자료형을 바인딩하고 싶으므로 Converter<S, T>의 S는 String, T는 TxType이 되겠습니다.

public class StringToTxTypeConverter implements Converter<String, TxType> {
@Override
public TxType convert(String source) {
return TxType.valueOf(source.toUpperCase());
}
}

그래서 이처럼 컨버터를 구현한 후 스프링에 등록하여 사용하게 되었습니다.

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToTxTypeConverter());
}
}

WebMvcConfigurer를 구현한 Configuration이 없다면 새로 만들어주시면 되고, 있으신 분들은 addFormatters()메서드를 재정의하면서 만든 컨버터를 registry에 추가해주시면 됩니다.

이제 이 API는 어떻게 동작할까요?

@GetMapping("/users/{userNo}/transactions")
public Something getTransactions(@PathVariable long userNo, @RequestParam TxType txType) {
// ...
}

이 API를 호출하면 컨트롤러로 진입하기 전 컨버터를 거치게 됩니다. 만약 TxType이 FOO와 BAR로 구성되어 있다면 아래의 테스트는 모두 통과하겠죠.

@Test
public void test() throws Exception {
mockMvc.perform(get("/users/123/transactions").param("txType", "FOO")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "Foo")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "foo")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "BAR")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "bAr")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "bar")).andExpect(status().isOk());
mockMvc.perform(get("/users/123/transactions").param("txType", "BAZ")).andExpect(status().isBadRequest());
}

컨버터 없이 String txTypeTxType txType으로 변경했다면 대부분 badRequest를 만났을 겁니다. 컨버터를 구현한 것만으로 간단하게 해결 되었습니다.

RequestBody

다 해결된 줄 알았더니 허들 하나가 남아있었습니다. 바로 RequestBody였습니다. 몇몇 POST API는 Payload를 JSON 형태로 받는데, 이곳에도 역시 String을 쓰고 있었지요. RequestBody가 JSON이라면 컨버터는 무용지물입니다.

@PostMapping("/users/{userNo}/transactions")
public Something cancelTransaction(@PathVariable long userNo, @RequestBody PostTransactionRequest request) {
// ...
}
----
public class PostTransactionRequest {
private String txType;
// ...
}

이럴 땐 어떻게 해야 할까요? RequestBody를 객체로 변환하기 위해 스프링은 기본적으로 Jackson을 사용하고 있기 때문에 이를 이해하고 활용할 수 있습니다.

우리는 DTO(Data Transfer Object, 여기서는 PostTransactionRequest)를 만들 때 보통 Getter, Setter를 같이 만들거나, Lombok을 사용하여 @Value 또는 @Data 어노테이션으로 대체하거나, 심지어는 Setter를 제공하지 않기도 합니다.

Jackson은 JSON을 객체로 역직렬(Deserialize)할 때 Setter를 우선 이용하고, 만약 객체의 Setter를 사용할 수 없다면 필드에 직접 접근합니다. 그러므로 필드와 Getter는 enum으로 제공하고 Setter에서 String을 enum으로 변환하는 술수를 부릴 수 있습니다.

public class PostTransactionRequest {
private TxType txType;
// ...
public void setTxType(String txType) {
this.txType = TxType.valueOf(txType.toUpperCase());
}

public TxType getTxType() {
return txType;
}
}

혹자는 Getter에서 변환하여 반환할 수도 있지 않냐고 할 수도 있습니다만 그렇게 되면 Getter를 쓸 때마다 변환을 하게 되고 캐시를 이용하게 되면 결국 위와 같은 형태가 될 것입니다.

마무리

고객이 API를 사용할 때 인자에 넣을 값을 대소문자 구분하여 넣는 것은 그리 어렵지 않습니다. 그러므로 API를 만들 때에는 되도록 도메인에서 사용하는 enum을 그대로 쓸 수 있도록 대문자로 강제하는 것이 명확성을 높이고 유지보수에도 용이할 것이라고 생각합니다. 다만, 이미 그렇지 못한 애플리케이션이라면 Converter를 이용하여 고객쪽과 서버쪽을 분리하고 서버쪽 코드를 더욱 간결하게 유지할 수 있을 것입니다.

저는 나중에 기회를 엿보다 Converter를 없애고 API 요청 인자를 대문자로 강제할 예정입니다. nginx 로그를 좀 살펴보니 이미 고객들도 대부분 대문자로 요청하고 있더군요.

읽어주셔서 고맙습니다.

--

--