이번 프로젝트에서 코딩테스트 문제 풀이 서비스를 개발하고 있다. 런타임에 코드를 컴파일 후 실행을 하는 로직을 만들어야 하는데 문제는 요청에 포함된 개발 언어에 맞는 서비스를 동적으로 호출해야 한다는 점이었다. 사실 서비스마다 각각 주입받아서 실행시켜도 되지만 이렇게 하면 코드가 너무 지저분해지고 다른 언어 서버스가 늘어나면 그게 따른 조건문을 추가로 작성해주어야 한다는 문제가 또 생겼다. 그래서 고민한 결과 다음과 같이 구현해 보기로 했다.
List로 빈 주입받기
일단 먼저 각 서비스에서 공통적으로 코드를 실행한다는 부분이 중복되기 때문에 인터페이스로 만들어 각 서비스를 구현하기로 했다.
public interface CodeRunService {
void run(RequestUserAnswer requestUserAnswer);
}
@Service
public class CodeRunServicePythonImpl implements CodeRunService {
@Override
public void run(RequestUserAnswer requestUserAnswer) {
// 내용 생략
}
}
@Service
public class CodeRunServiceJavaImpl implements CodeRunService {
@Override
public void run(RequestUserAnswer requestUserAnswer) {
// 내용 생략
}
}
빈 등록은 @Service를 사용하여 컴포넌트 스캔으로 등록하도록 했다.
@Slf4j
@RequestMapping("/code")
@RequiredArgsConstructor
@RestController
public class CodeRunController {
private final List<CodeRunService> codeRunServiceList;
// 내용 생략
}
이렇게 등록한 빈을 주입받기 위해 List를 사용했다.
그럼 과연 위 등록한 빈들이 정말로 리스트에 들어오는지 확인해 보자.
@Slf4j
@SpringBootTest
class CodeRunControllerTest {
@Autowired
ApplicationContext applicationContext;
@Test
void checkCodeRunServiceBeans() {
Map<String, CodeRunService> beansOfType = applicationContext.getBeansOfType(CodeRunService.class);
for (Map.Entry<String, CodeRunService> entry : beansOfType.entrySet()) {
log.info("beanName={} / Service={}", entry.getKey(), entry.getValue());
}
}
}
간단한 테스트를 통해 확인해 보자.
설정용 클래스를 만들어 서비스 관련 빈만 등록하는 게 실행 속도는 빠를 테지만 그냥 간단히 확인만 할 예정이기 때문에 ApplicationContext를 주입받아 사용했다.
보면 CodeRunService로 캐스팅 가능한 서비스들이 모두 Map에 포함되어 있는 것을 확인할 수 있었다.
컨트롤러 살펴보기
이제 컨트롤러에서 어떻게 구현했는지 살펴보자.
@Slf4j
@RequestMapping("/code")
@RequiredArgsConstructor
@RestController
public class CodeRunController {
private final List<CodeRunService> codeRunServiceList;
@PostMapping("/answer")
public String checkAnswer(@RequestBody RequestUserAnswer requestUserAnswer) {
CodeRunService codeRunService = Arrays.stream(Lang.values())
.filter(lang -> lang.name().equals(requestUserAnswer.getLang().toUpperCase()))
.map(Lang::getcodeRunImplClass)
.map(codeRunImplClass ->
codeRunServiceList.stream()
.filter(codeRunImplClass::isInstance)
.findFirst()
.orElseThrow(UserRequestLangException::new)
)
.findAny()
.orElseThrow(UserRequestLangException::new);
codeRunService.run(requestUserAnswer);
return "good";
}
private enum Lang {
JAVA(CodeRunServiceJavaImpl.class),
PYTHON(CodeRunServicePythonImpl.class);
private final Class<? extends CodeRunService> codeRunImplClass;
Lang(Class<? extends CodeRunService> codeRunImplClass) {
this.codeRunImplClass = codeRunImplClass;
}
public Class<? extends CodeRunService> getcodeRunImplClass() {
return codeRunImplClass;
}
}
}
리턴값은 아직 모두 구현이 되지 않아서 일단 good을 HTTP 바디에 담아 응답하도록 했다.
구현 로직은 다음과 같다.
먼저 유저가 사용한 언어와 해당 언어에 맞는 서비스를 매핑시키기 위해 enum을 사용하였다. 이 부분은 Map을 사용해도 되지만 enum을 사용하면 좀 더 알아보기 쉽게 코드를 작성할 수 있을 것 같았고, 나중에 추가적인 작업이 필요할 때 메서드를 만들어 사용할 수 있기 때문에 사용했다.
참고로 실제 코드 실행은 List로 주입받은 것에서 인스턴스를 찾아 실행하기 때문에 enum에 있는 클래스는 Class를 이용하여 List의 어떤 클래스를 사용할 지만 결정하는 용도이다.
List의 서비스 선택 로직은 스트림을 사용하였다.
enum의 요소 중에서 요청으로 받은 개발 언어와 같은 것을 선택하고,
해당 언어의 Class 정보로 List에 주입받은 빈들과 비교하여 어떤 것이 타입이 같은 지 확인한다.
같은 것이 있으면 그대로 반환하고, 반환 값이 Optional이기 때문에 제대로 변환을 하지 못했으면 예외를 발생하도록 했다.
최종적으로 이렇게 얻은 서비스를 가지고 호출을 하면 원하던 대로 호출이 되는 것을 확인할 수 있다.
++ 추가 ++
기존 코드가 너무 복잡해서 그냥 Map을 이용하는 방식으로 변경했다...
글도 수정할까 했는데 우여곡절도 남겨두면 나중에 다시 실수 안 할거 같아서 그냥 남겨두었다.
https://megamaker.tistory.com/411
[Spring Boot] 오늘 얻은 교훈... 쉽게 갈 수 있으면 그냥 쉬운 방법을 선택하자
이전에 사용자 코드 실행 컨트롤러를 enum과 스트림을 이용해서 작성한 적이 있었다.그런데 아무리봐도 뭔가 아니었다... 너무 불필요하게 복잡한 느낌이었다.코딩테스트 문제를 풀 때 너무 돌아
megamaker.tistory.com
잠깐 예외처리 과정 알아보기
위 컨트롤러에서 살펴본 예외는 이름을 보면 알겠지만 내가 커스텀으로 만든 예외이다.
코드를 살펴보면 예외를 발생시키지만 어디서도 해당 예외를 잡지 않는데 그 이유는 서블릿 컨테이너까지 예외를 던질 것이기 때문이다.
사실 이전까지는 컨트롤러에서 모든 예외를 잡았었다.
다음 코드는 이전에 진행했던 프로젝트의 한 컨트롤러 부분이다.
@PostMapping
public ResponseEntity<?> saveOrder(Authentication authentication, @RequestBody RequestOrder requestOrder) {
Long userId = Long.valueOf(String.valueOf(authentication.getPrincipal()));
requestOrder.setUserId(userId);
String error = "";
try {
orderService.save(requestOrder);
return ResponseEntity.created(URI.create(environment.getProperty("client.order_result"))).build();
} catch (QuantityException e) {
error = "재고 수량보다 적게 주문해주세요";
} catch (NoProductException e) {
error = "상품 조회에 실패했습니다";
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
실제 로직보다 예외처리를 위한 로직이 더 많아서 보기 불편하다.
체크 예외의 경우, 컴파일 단계에서부터 오류가 발생해서 무조건 잡아야 하지만, 런타임 예외인 언체크 예외의 경우도 마찬가지로 컨트롤러에서 다 잡아야 할까?
사실 스프링에서는 아무것도 안 해도 예외처리에 대한 부분을 미리 구현해두고 있다.
만약 View까지 있는 경우, 템플릿에 error로 4xx.html 등을 만들어 두면 해당 페이지를 오류 페이지로 자동으로 사용한다.
하지만 우리는 API를 개발하고 있으므로 이건 상관이 없다. 그렇지만 API에 관한 부분도 당연히 스프링에서 기본으로 제공을 하고 있다.
만약 로직 상에서 예외가 발생하거나 response.sendError()가 있을 경우, 서블릿 컨테이너에서는 이를 /error로 새로운 요청을 만들어 다시 컨트롤러로 보내버린다. 스프링에서는 미리 BasicErrorController라는 컨트롤러를 미리 등록해 두었기 때문에 /error로 오는 요청을 매핑해 처리한다.
위 메서드를 보면 요청의 Accept 헤더에 따라 ModelAndView를 반환하거나(템플릿 엔진 사용 시), API를 위한 ResponseEntity를 반환하는 것을 볼 수 있다. 그렇기 때문에 우리가 따로 어떤 처리를 하지 않아도 다음과 같은 응답을 볼 수 있는 것이다.
server:
error:
include-message: always
application.yaml에 위와 같은 설정을 추가한다면 다음과 같은 에러 메시지도 확인할 수 있다.
main() 메서드만 실행 가능하도록 허용했는데 메서드명을 잘못 작성해서 오류가 응답됐다.
그런데 여기서 의문인 점은 서버 내부에서 실행 문제가 발생한 것인데 400 Bad Request로 응답되었다는 점이다.
사실 이 부분도 스프링에서 처리해 주는 부분으로 나는 예외에 다음과 같이 작성하기만 했다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "유저 코드 런타임 에러")
public class UserMethodLoadException extends RuntimeException {
public UserMethodLoadException() {
super();
}
}
스프링은 인터셉터의 afterCompletion()을 호출하기 전에 ExceptionResolver를 호출하는데 이것은 postHandle()과는 달리 예외가 발생해도 호출된다. 오히려 예외를 다루기 위한 방법으로 사용된다.
스프링에서 기본으로 등록하는 ExceptionResolver 중에서 ResponseStatusExceptionResolver라는 것이 있는데 여기서 위 어노테이션이 붙은 예외가 발생했을 때 응답 코드와 오류 메시지를 개발자가 설정한 것으로 바꿔주는 것이다.
그래서 사용자는 500 코드가 아닌 400 응답 코드를 볼 수 있었던 것이다.
실제로 요청해 보기
이제 코드를 살펴봤으니 실제로 개발 언어를 다른 것으로 바꿨을 때 그에 맞는 다른 서비스가 실행될 것인지 확인해 보자.
만약 요청에서 언어를 java로 설정했을 경우, 메서드 내의 내용을 실행하여 문자열을 출력하고,
python으로 설정했을 경우에는 "python 코드 실행"이라고 남기기로 했다.
자바의 경우 동적으로 런타임에 코드를 컴파일하여 실행할 수 있도록 해놨는데 파이썬은 아직 구현이 안 되어서 이렇게 했다.
Java 서비스 실행
먼저 자바를 테스트해보았다.
일단 컨트롤러에서 설정한 응답인 good이라는 문자열이 잘 응답된 것을 확인할 수 있었다.
요청에서 보낸 내용은 "Java 실행 Hello!!!" 문자열을 출력하는 간단한 코드이다.
그럼 정말 해당 문자열이 출력되었는지 확인해 보자.
정말로 해당 문자열이 잘 출력되었다.
Python 서비스 실행
파이썬 서비스는 아직 구현되지 않았기 때문에 그냥 언어만 바꿨을 때 제대로 서비스가 실행되는지만 보면 된다.
body의 lang 부분을 python으로 바꾸고 실행하니 일단 컨트롤러 응답으로 good이라는 문자열이 잘 응답되었다.
약간 잘려서 잘 안 보일 수도 있는데 원하던 대로 python 서비스의 로그 출력이 잘 되었다.
이번 프로젝트에서는 동적 컴파일 후 실행이나 예외 처리 등 새로운 내용을 많이 배우고 프로젝트에 적용해 볼 수 있어서 재밌기도 했고 굉장히 의미 있다고 느껴졌다. 다음 할 일은 사용자 코드의 실행 시간을 측정하고 응답을 잘 완성해서 보내주는 것인데 여기서는 여러 서비스 간의 공통 로직을 효율적으로 처리하기 위해 AOP를 도입해 보면 좋을 것 같다. 그리고 지금 구현한 예외 처리도 나쁘지 않지만 @ExceptionHandler나 @RestControllerAdvice를 통해 더 유연하게 예외를 처리해 줄 수도 있어서 나중에 이 방식으로 변경해 봐야겠다.
'공부 > Spring' 카테고리의 다른 글
[Spring Boot] Category 계층 구조 도메인 객체로 변환하기 (0) | 2025.01.05 |
---|---|
[Spring Boot] 오늘 얻은 교훈... 쉽게 갈 수 있으면 그냥 쉬운 방법을 선택하자 (0) | 2024.10.26 |
[Spring] STOMP 웹소켓 채팅 테스트해보기 (0) | 2024.09.20 |
[Spring] 타임리프(Thymeleaf) 알아보기 2 (1) | 2024.09.19 |
[Spring] 타임리프 알아보기 1 (1) | 2024.09.17 |