Spring Framework에서 Bean을 주입받기 위해 lombok에서 제공하는 RequiredArgsConstructord어노테이션을 사용하셔 보셨을 것입니다.
하지만 이때 Qualifier를 사용하게 된다면 원하던 Bean을 주입받지 못하는 현상이 발생합니다.
이 글에서는 왜 @Quailifier가 정상적으로 작동하지 않는 이유와 정상적으로 작동하려면 어떻게 해줘야 하는지에 대해 알아보도록 하겠습니다.
왜 적용이 안되는가?
이를 위해서는 lombok이 어떻게 빈을 주입받을 수 있게 해주는지 즉 작동 방법에 대해 알아볼 필요가 있습니다.
우선 우리는 lombok을 Getter, Setter, AllArgsConstructor, Builder 등 우리가 코딩을 해서 만들어도 되지만 컴파일할 때 자동으로 명시한 어노테이션의 기능들을 자동으로 생성되게 도와주는 라이브러리입니다.
위와 같이 컴파일을 거치게 되면 명시된 어노테이션은 사라지고 개발자가 직접 작성하지 않은 코드가 생성되는 것을 확인할 수 있습니다.
그럼 이제 어떤 역할을 하는 라이브러리인지 알았으니 오늘의 주제 @RequiredArgsConstructord는 왜 @Qualifier가 적용되지 않는가? 에 대해 알아보도록 하겠습니다.
우선 @RequiredArgsConstructord와 @Qualifier에 대해 모르시는 분들은 아래 글을 보고 마저 봐주시기 바랍니다.
우선 일반 생성자로 만들고 Compile을 할 경우 아래와 같이 생성자 파라미터에 Qualifier로 어떤 Bean을 주입받을 것인지 명시가 되어있습니다.
@RestController
@RequestMapping({"/connect"})
public class ConnectTestController {
private final Test bean;
public ConnectTestController(@Qualifier("test1") Test bean) {
this.bean = bean;
}
@GetMapping
public String connect() {
System.out.println(this.bean.text);
return "OK";
}
}
아래 코드는 @RequiredArgsConstructord를 사용하여 만든 코드를 컴파일한 코드입니다.
@RestController
@RequestMapping({"/connect"})
public class ConnectTestController {
@Qualifier("test1")
private final Test bean;
@GetMapping
public String connect() {
System.out.println(this.bean.text);
return "OK";
}
public ConnectTestController(final Test bean) {
this.bean = bean;
}
}
다른 점이 보이시나요??
위 생성자를 직접 생성하여 Qualifier를 사용한 경우 생성자에 파라미터로 주입받을 때 명시적으로 붙어있지만 lombok에서 지원해 주는 어노테이션을 사용할 경우 final로 Test타입의 bean을 주입받겠다고 작성되어 있습니다.
이럴 경우 Bean의 명칭이 달라 test1으로 지정한 Bean을 주입받지 못하고 다른 Bean을 주입받거나 아예 못 받는 경우가 생기는 것입니다.
어떻게 해결할 수 있는가??
우선 가장 쉬운 방법은 위에 보여드린 것처럼 직접 생성자를 작성하는 방법이 있고, @Autowired를 사용하여 필드 주입을 해주는 방법이 존재합니다.
하지만 우리가 lombok을 사용하는 가장 큰 이유 코드를 덜 작성하고 싶다!! 는 니즈를 충족하는 방법이 있는데, 이는 lombok.config를 설정하는 것입니다.
src/main/java경로에 lombok.config파일을 생성 후 아래 코드를 작성해 주면 lombok에서 compile시 코드를 만들어 줄 때 해당 설정을 통해 Qualifier를 반영하여 생성해 주는 것을 확인할 수 있습니다.
@RestController
@RequestMapping({"/connect"})
public class ConnectTestController {
@Qualifier("test1")
private final Test bean;
@GetMapping
public String connect() {
System.out.println(this.bean.text);
return "OK";
}
public ConnectTestController(@Qualifier("test1") final Test bean) {
this.bean = bean;
}
}
해당 객체가 Bean으로 등록될 때 생성자에 명시되어 있는 파라미터들을 즉 Bean들을 주입받는 방식입니다.
가장 기본적이라 생각하며, 가장 코드 줄이 긴 방법이지만 가장 직관적인 만큼 오류가 가장 발생하지 않는 방법입니다.
@RestController
@RequestMapping("/connect")
public class ConnectTestController {
//
private final Test testDescription;
public ConnectTestController(Test testDescription) {
//주입받는 부분
this.testDescription = testDescription;
}
@GetMapping()
public String connect() {
System.out.println(testDescription.testDescription());
return "OK";
}
}
2. @RequiredArgsConstructor
lombok의 @RequiredArgsConstructor를 사용하는 방법입니다.
@RestController
@RequestMapping("/connect")
@RequiredArgsConstructor
public class ConnectTestController {
//
private final Test testDescription;
@GetMapping()
public String connect() {
System.out.println(testDescription.testDescription());
return "OK";
}
}
위와 같이 생성자를 직접 명시하지 않고 사용하는 방법이며 final로 지정된 전역변수들에 한해서 빈을 자동으로 주입해 줍니다.
3.@Autowired
위 방법들과 다르게 필드 주입 혹은 수정자 주입받을 때 사용하는 방법입니다.
기본적으로 Springframework에서 제공해 주는 @Autowired어노테이션을 사용합니다.
필드 주입
@RestController
@RequestMapping("/connect")
public class ConnectTestController {
//
@Autowired
private Test testDescription;
@GetMapping()
public String connect() {
System.out.println(testDescription.testDescription());
return "OK";
}
}
수정자 주입
@RestController
@RequestMapping("/connect")
public class ConnectTestController {
//
private Test testDescription;
@Autowired(required = false)
public void setTestDescription(Test testDescription) {
this.testDescription = testDescription;
}
@GetMapping()
public String connect() {
System.out.println(testDescription.testDescription());
return "OK";
}
}
위처럼 두 가지 방법으로 구현이 가능한 방법입니다.
생성자 주입방식보다 번거롭고 코드 줄이 길어 가독성이 불편하며, 개발자 실수가 많이 이뤄질 수 있는 방법으로 권장하는 Bean주입 방법은 생성자 주입방법을 권장하고 있습니다.
기타
@Qualifier
Qualifier은 동일한 타입의 Bean들이 많을 때 어떤 Bean을 주입받을지 명시할 때 사용하는 어노테이션입니다.
@Component
public class Test {
public String text = "test";
@Bean
@Qualifier("test1")
public Test testDescription() {
Test test = new Test();
test.text = "hello";
return test;
}
}
우선 Bean을 2개 지정해 보도록 하겠습니다. 하나는 Test라는 Bean이고 하나는 test1이라는 Bean을 등록하였습니다.
위처럼 @Qualifier를 지정하여 사용할 경우 해당 이름으로 Bean이 생성됩니다.
@RestController
@RequestMapping("/connect")
public class ConnectTestController {
//
@Autowired
@Qualifier("test1")
private Test bean;
@Autowired
private Test test;
@GetMapping()
public String connect() {
System.out.println(bean.text);
System.out.println(test.text);
return "OK";
}
}
이후 위처럼 각각 정의를 하고 실행을 하였을 때 아래와 같은 실험 결과를 얻어볼 수 있습니다.
이처럼 같은 타입을 반환하는 Bean들 중 하나를 지정하여 주입받을 때 사용하는 어노테이션입니다.
최근 지금 까지 해온 것들을 되돌아보며 제가 3년차 개발자가 되기까지 많은 작업을 하였지만,
막상 저를 표현할 수 있는 것들이 준비되어있지 않다고 생각하였습니다.
제가 비록 거의 3년간 바쁘게 살아온 거는 사실인거 같습니다. 블로그도 회사 일정에 밀려 중단한 적도 많았고, 사이드 프로젝트, 스터디 등등 시작을 하였다가 일정이 안맞아서, 야근해야해서 등등의 이유로 실패한 적도 많은 것도 사실입니다. ( 물론 힘들어서 못한 점도 있습니다. )
다만 힘든 일정속에서도 계속하여 자기개발에 힘을 써왔고, 이제는 이를 증명하기 위해 그리고 저의 기술을 다듬고 제가 하고 싶은 개발을 하기 위해 사이드 프로젝트를 시작합니다.
프로젝트가 끝날때까지 프로젝트 진행 사항을 블로그에 글로 남김으로써 어떤 부분을 고민하였고, 어떤 문제점들을 만났으며, 이를 어떻게 해결하였는지 등이 잘 나타날 수 있게 글을 작성할 것이며, 시작은 기획부터 시작하겠습니다.
이것이 무슨 말이냐 우리가 기본적으로 사용하는 일반적인 HTTP 통신은 Client가 요청을 보내면 서버는 해당 요청 건에 대해서만 응답을 할 수 있는 규칙을 가지고 있습니다.
이럴 경우 우리가 개발한 서버에서 어떤 작업이 이뤄졌을 때 Client가 알 수 있는 방법은 Client의 요청이 있을 때까지 기다리는 방법 밖에는 없습니다. 하지만 이렇게 구현을 하자면 Client는 일정 시간이 지날 때마다 서버를 조회하여 데이터를 최신화 시켜주는 방법 밖에 없을 것이고 이는 무분별한 호출로 인한 리소스 낭비로 인해 문제를 야기할 수도 있습니다.
그럼 서버에서 응답을 보내서 Client에서 처리하면 어떨까? 해서 나온 것이 웹소켓과 SSE기술입니다.
웹소켓의 경우 양방향 통신으로 handshaking방식으로 이루어집니다. 이는 Client와 서버의 통신이 연결되는 방식을 말하며 웹소켓에서 한번 연결된 통신은 지속적으로 유지되며, 이 통신을 통해 Client와 서버는 데이터를 주고 받을 수 있습니다. 이는 채팅과 같이 실시간 소통이 이뤄져야하는 기능을 구현 할 때 많이 사용되는 방법입니다.
하지만 알람과 같이 Client가 서버로 요청을 보내지 않아도 서버에서 특정 작업이 생겼을 때 Client가 알게 하고 싶다면 WebSocket방식은 과한 기능 구현인 것 같을 수 있습니다. 그럴때는 SSE를 적용하는 것을 고민해볼 수 있습니다.
SSE는 Client의 요청을 기다리지 않고 서버에서 Client로 요청을 보내는 기술로 서버에서 단방향 통신으로 Client로 응답을 보낼 수 있습니다. Client가 처음 한번의 요청을 통해 Client와 서버의 연결이 설정된 후 서버의 일방적인 데이터 전송이 가능해 집니다.
사용방법(Client - React)
Client의 설정은 간단합니다. 위에서 말했듯 Client에서의 작업은 초기 연결 설정을 위한 요청과 Event를 수신하여 작업할 부분만 작업해주면 됩니다.
useEffect(() => {
let eventSource: EventSource;
if (requestId.length) {
// 서버와 연결
eventSource = new EventSource(defaultURL + "/connect/" + requestId);
// 이벤트 수신 시 수행할 작업들
eventSource.addEventListener("message", handleEventMessage);
eventSource.addEventListener("error", handleEventError);
}
return (() => {
if (requestId) {
// 페이지가 종료될 때 설정한 이벤트들도 제거
eventSource.removeEventListener("message", handleEventMessage);
eventSource.removeEventListener("error", handleEventError);
}
})
}, [requestId]);
위 설정은 서버와의 초기 연결을 요청 보내는 부분입니다.
new EventSource()는 초기 서버 연결을 담당해주는 부분이며, 내부 파라미터로 url을 주입해주면 해당 URL로 Get요청을 보내줍니다.
이때 요청 Header의 Content-Type은 text/event-stream으로 지정되어 있습니다.
사용방법(Back-Spring)
서버는 Client보다 작업량 자체는 많지만 다른 웹소켓과 같은 기술들에 비하면 훨씬 적은 설정으로 구현할 수 있습니다.
우선 서버는 Client로부터 연결을 위한 요청이 들어온다면 HTTP Method와 produces즉 미디어 타입을 명시 해주어야합니다.
@GetMapping(value = "/connect/{requestId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect(@PathVariable String requestId) {
// SseEmitter설정을 위해 sseService의 register을 사용
return sseService.register(requestId);
}
하지만 위에 작성된 케이스는 오류를 일부러 발생하기 위해 간단하게 테스트한 경우이고 실제 업무에서는 어떤 상황일 때 발생할까요?
아무래도 실제 사용하는 케이스가 필요하다 생각합니다.
그리하여 일반적인 API호출하는 것처럼 Get API를 하나 만들어 주도록 하겠습니다.
@RestController
@RequestMapping("/error")
public class ErrorController {
@Autowired
private ObjectMapper objectMapper;
@GetMapping
public QueryData<TestData> test() throws JsonProcessingException {
String data = "{\"name\": \"홍길동\", \"description\": \"데이터 파싱 테스트입니다.\", \"field\": {\"data\": \"파싱이 될까요?\"}}";
QueryData<TestData> queryData = new QueryData<>();
queryData.setResponse(objectMapper.readValue(data, TestData.class));
return queryData;
}
}
일반적인 값을 반환해주는 API입니다.
public class QueryData<T> {
private T response;
}
Response를 Wrapping할 때 사용한 클래스입니다.
단순하게 response 를 담기 위해 만들었습니다.
@SpringBootTest
@AutoConfigureMockMvc
public class ErrorControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
public void dataParsingTest() throws Exception {
mockMvc.perform(get("/error").contentType(MediaType.APPLICATION_JSON))
.andDo((result) -> {
QueryData<TestData> queryData = objectMapper.readValue(result.getResponse().getContentAsString(Charset.defaultCharset()), QueryData.class);
//에러 발생 부분
TestData testData = queryData.getResponse();
})
.andExpect(status().isOk());
}
}
테스트 코드입니다.
SpringBoot환경에서 Mock을 사용하여 테스트 진행하였습니다.
위 테스트 코드를 실행해 보시면 response로 받은 데이터를 역직렬화하여 사용하였으나 제너릭에 명시한 타입으로 QueryData의 response가 TestData가 아닌 LinkedHashMap으로 타입이 정의되어 캐스팅할 때 에러가 발생하는 것을 확인할 수 있습니다.
제가 경험했던 오류 케이스도 이와 같았으며, Response Data가 한번 객체로 감싸져 있으며, 타입이 명확하지 않아 Object로 인지되어 역직렬화가 된경우 데이터를 꺼내 사용할 때 캐스팅이 이뤄지게 되는데 이때 에러가 발생합니다.