Spring Boot 기반으로 설계하여 request 와 얻게되는 response를 구분하여 설계를 시작합니다
Proxmox라는 VDI 시스템은 기본적으로 Rest 방식을 이용하여 API 처럼 사용됩니다. 시작하기 앞서 공식 문서를 보며 request에 관련된 설계를 합니다
https://pve.proxmox.com/pve-docs/api-viewer/index.html#/nodes
이미 짜여진 틀에 맞추어 API 공식 문서를 토대로 API 테스트와 분석을 시작합니다
결과물의 도메인 조차 이해하지 못한다면 완성도는 기대하기 어렵기 때문이죠
제가 이해한 바는 아래와 같습니다
VDI
란 가상 데스크톱 인프라이며 전체적인 기반은 "인프라"가 핵심입니다.
회사에서 사용되는 회사 자체적인 기술적인 정보를 은닉하고 기밀성을 갖출 수 있게 하는 시스템입니다. VDI에서는 하이퍼바이저가 서버를 가상 머신으로 세분화하고, 가상 머신은 가상 데스크톱을 호스팅하며, 사용자는 각자의 기기를 통해 가상 데스크톱에 원격으로 액세스합니다.이러한 시스템이 있기 전에는 물리적인 분리 = 컴퓨터를 N 대로 관리 하는 방식을 채택하여 각 컴퓨터에는 권한을 부여하여 각 사용에 맞추어 사용하고는 했었습니다.
하지만 이러한 관리는 사람이 직접적으로 개입하여 관리해야 할 뿐만아니라 비용적인 측면에서도 두개의 하드웨어를 사용하여야 한다는 점에서 불편함이 많았죠.이러한 점을 개선하여 등장한 것이 VDI 입니다. 앞서 설명한것과 같이 가상의 머신 (Virtual Machine) 을 필요에 따라 요청하여 사용할 수 있으며 이에 Access 할 수 있는 권한 또한 컴퓨터를 통하여 설정이 가능하게 되었죠.
VDI 이점 :
원격 액세스
,비용 절감
,보안
,중앙 집중식 관리
이외에도 원격작업, Bring your Own Device, 반복 작업 또는 교대 작업에 이점을 가집니다
이렇게만 보면 너무나 완벽한 시스템이지만 왜 모든 회사에서 사용하고 있지 않는지 의문점이 들테지만.. 우선 인프라를 다룬다고 하여 VDI 시스템을 완벽히 다루는 것은 아니며 이에 정통하는 전문가가 따로 존재하며 각 VDI를 제공하는 업체마다 고유한 기능, 사용법을 가지기에 접근성이 다소 떨어집니다.
뿐만 아니라, 초기 설치 비용또한 만만치 않아 규모가 작은 경우 비용 절감의 효과보다는 유지 비용이 더 나올 수 있다는 마법같은 일이 일어납니다.
여기까지 이해했을때 그렇다면 도커(Docker) 와는 무엇이 다른것인가 의문점을 가지게 되었죠
가상화 이전에는 하나의 서버에 하나의 어플리케이션만 구동시키는 것이 일반적이였습니다. 그렇기에 하나의 서버에 남는 자원이 많았고 전체적으로 비효율적이였죠.
도커와 VM 을 이 틀에서 보면 같은 불편함을 해소 합니다
큰 차이점은 게스트 OS의 유무입니다. VDI의 VM에서는 Guest OS가 설치되는데 도커에는 그렇지 않습니다. 이는 자원의 효율성에서 큰 차이를 띄게 되는데 VM 을 사용하게 될때 하나씩 늘릴때 마다 OS 자원을 할당하여야 하지만 도커는 구동하는데 필요한 패키지만 있다면 컨테이너를 구동할 수 있습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'junit:junit:4.13.1'
implementation 'org.apache.httpcomponents:httpclient:4.5.13'
implementation 'org.json:json:20220320'
implementation 'io.springfox:springfox-boot-starter:3.0.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
처음에는 spring doc에 대해 생각을 못하고 있어 view를 하나 하나 만들어야 하나 너무 고민이 많았고 Front 쪽은 정말 자신이 없어 보류중에 있다가 Swagger 라는 것을 처음으로 알게 되어 적용하게 되었습니다
이제 POSTMan 을 통하여 테스트를 진행합니다
이후 문서화를 위하여 Collection으로 하나 팠습니다!
proxmox의 경우 Admin 사용자의 권한으로 지정한 IP 에 권한을 주는 방식이었는데 인증서 대체 url 과 타겟 url이 일치하지 않아 SSL 인증에 다소 어려움이 있었습니다.
인증서 다운로드 이후 환경변수로 해당 인증서의 PATH를 지정하면 풀 수 있다고 알고 있었는데 많은 시행착오에도 인증이 되지 않아 우회하는 방식을 선택하게 되었습니다
PostMan 에는 이를 disable하는 기능이 있어 다행히도 테스트에는 큰 지장이 없었습니다
로그인 부분 : (Before Each) 모든 테스트에 앞서 티켓이 없으면 api를 테스트할 수 없기에 티켓값을 얻어 파싱한뒤 환경변수로 지정하는 것이 시작이었습니다
처음 알게 되었는데 PostMan 에는 Tests를 통하여 javaScript를 실행할 수 있어 간편하게 저장할 수 있었습니다. (공부하는것을 기록하고자 하는 목적이기 때문에 따로 정확한 코드는 제공하지 않습니다)
이를 이용하여 global 로 설정하여 Cookie에 이 토큰값을 들고 다닐 수 있도록 설정하게 되었습니다. 헤더의 preset 으로 지정하여 각 기능별로 추가하기 용이하도록 하였습니다
모든 기능상의 문제가 발견되지 않아 다음 단계로 넘어갔습니다
우선 DTO입니다
다음은 RestTemplateConfig 입니다
PostMan에서는 간편하게 Disable 버튼 하나로 퉁칠 수 있었지만 코드로는 참 쉽지 않은 문제였습니다. IntelliJ 의 Cert 쪽도 건드려 보고.. 환경변수를 통해서도 인증서를 지정해보려 하였지만 무수한 인증 실패를 겪으며 Stack Overflow 형들에게 도움을 받았습니다
@Configuration
public class RestTemplateConfig {
// 현재 대체 인증서와 원격 주소의 불일치로 SSL 을 disable 설정 해줍니다
@Bean
public RestTemplate restTemplateByPassSSL()
throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException {
TrustStrategy acceptingTrustStrategy = (X509Certificate[] chain, String authType) -> true;
HostnameVerifier hostnameVerifier = (s, sslSession) -> true;
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext, hostnameVerifier);
CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(csf).build();
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setHttpClient(httpClient);
return new RestTemplate(requestFactory);
}
}
본래에는 Test에서 TestRestTemplate 을 써보려 하였지만 이 문제로 그냥 RestTemplate으로 대체할 수 밖에 없었습니다.
다른 분들도 SSL 관련 문제를 겪고 있다면 위 코드를 Config 로 지정하신다면 우회할 수 있을 겁니다.
에러 페이지
사실 spring doc을 사용한다면 필수적인 요소는 아니지만 초기의 설계에서는 모든 View 를 직접 처리해야지! 하는 생각으로 에러페이지를 만들게 되었습니다
@ApiIgnore
@Controller
public class ExceptionHandlingController implements ErrorController {
private final String ERROR_404_PAGE_PATH = "/error/404";
private final String ERROR_500_PAGE_PATH = "/error/500";
private final String ERROR_ETC_PAGE_PATH = "/error/error";
@ExceptionHandler(Throwable.class)
@GetMapping("/error")
public String handleError(HttpServletRequest request, Model model){
// 에러코드 획득
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
// 에러코드에 대한 상태 정보
HttpStatus httpStatus = HttpStatus.valueOf(Integer.valueOf(status.toString()));
if(status!=null){
// HttpStatus 와 비교하여 페이지 분기를 나누기위한 변수
int statusCode = Integer.valueOf(status.toString());
// 404 Error
if(statusCode== HttpStatus.NOT_FOUND.value()){
// 에러 페이지에 표시할 정보
model.addAttribute("code",status.toString());
model.addAttribute("msg", httpStatus.getReasonPhrase());
model.addAttribute("timestamp",new Date());
return ERROR_404_PAGE_PATH;
}
else if(statusCode==HttpStatus.INTERNAL_SERVER_ERROR.value()){
// 에러 페이지에 표시할 정보
model.addAttribute("code",status.toString());
model.addAttribute("msg", httpStatus.getReasonPhrase());
model.addAttribute("timestamp",new Date());
return ERROR_500_PAGE_PATH;
}
}
// 정의한 에러 외 모든 에러는 error/error 로 보낸다
return ERROR_ETC_PAGE_PATH;
}
}
기본적으로 NotFound 에러와 500 에러를 처리하였습니다. 사실 500 에러의 경우에는 서버쪽 문제라 메시지는 안띄우는것이 일반적이지만 습관적으로 처리해버렸습니다.
요즘은 Enum을 통하여 커스텀 에러를 설정하는 법도 많은것 같은데 이후에 토이 프로젝트로 한번 도전해볼까 합니다
Service
기본적으로 자주쓰이는 메서드의 경우 util으로 따로 묶어 관리하였습니다.
웹이 아닌 API Test 목적이기 때문에 하나의 서비스로 묶어 BaseService 로 관리하였습니다
Utility
public static HttpHeaders getDefaultHeader(String Token1, String Token2){
// 로그인 이후의 헤더는 모두 동일한 보일러 플레이트 코드
HttpHeaders httpHeaders = new HttpHeaders();
MultiValueMap<String, String> headerValues = new LinkedMultiValueMap<>();
headerValues.add(HttpHeaders.ACCEPT, "*/*");
headerValues.add(HttpHeaders.COOKIE, "AuthCookie=" + Token);
headerValues.add(HttpHeaders.COOKIE, "Cookie=en");
headerValues.add("CSRFPreventionToken", Token);
headerValues.add(HttpHeaders.HOST, host);
headerValues.add(HttpHeaders.USER_AGENT, user_agent);
httpHeaders.addAll(headerValues);
return httpHeaders;
}
public static UriComponentsBuilder getUriComponents (Map<String,String> parameters, String url){
// query param 으로 파라미터를 넘겼을때 제대로 인식하는것을 확인
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
for (Map.Entry<String, String> entry : parameters.entrySet()) {
builder.queryParam(entry.getKey(), entry.getValue());
}
return builder;
}
자바를 하면서 객체지향을 살리기 위해, 독립성을 보장하기 위한 낮은 결합도, 높은 응집도를 위한 코딩 습관을 기르고자 노력하고 있습니다
UriComponentsBuilder 의 경우는 uri에 param들을 때려박는 그런 친구입니다
초기에 Post 에 넣는 파라미터를 바디에 넣을 생각을 못한것을 반성하게 되네요..
public static Map<String,String> validateVariables (Map<String,String> params){
// 변수이름에 - 를 넣을수 없어 DTO 매핑할 수 없던 문제를 해결
for (String key : params.keySet()) {
if(key.contains("_")){
String value = params.get(key);
params.remove(key);
key = key.replace('_','-');
params.put(key,value);
}
}
return params;
}
public static Map<String, String> ifEmptyDontAdd (Map<String,String> params){
// 딕셔너리에서 null인 key를 대상으로 uri param으로 지정하지 않습니다
Map<String, String> newMap = new HashMap<>();
for (String key : params.keySet()) {
if(params.get(key)!=null){
if(params.get(key).length() > 0) newMap.put((key),params.get(key));
}
}
return newMap;
}
위 두개의 경우는 자주 쓰일것같아 만들어 놓은 친구이나 그렇게 자주 쓰이지는 않은 슬픈 메서드들입니다.
spring doc에서 파라미터를 받게 될때에 해당 파라미터에 아무것도 입력하지 않으면 "" 로 남는 문제로 인하여 request가 꼬이는 문제를 발견하여 null이거나 length가 0일때는 해당 키를 삭제 시키도록 하였습니다
그리고 DTO를 생성하는 과정에서 "-" 를 포함한 파라미터 ex:hello-robin 과 같은 파라미터의 경우 변수 이름으로 지정할 수 없는 문제로 "_" 로 변수 이름을 만들게 되었는데 request parameter 가 되기전 변환해주는 메서드입니다
본격적인 테스트를 시작합니다
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BaseServiceTest {
@Autowired
private BaseUtility baseUtility;
@Autowired
private RestTemplate restTemplate;
우선 의존성 주입된 친구들을 불러오고
private String Token;
토큰 값을 지정해줍니다.
저는 처음 알게된 사실인데 Test에서는 게터세터 롬복이 적용이 되질 않더군요.. ?
그래서 직접 게터 세터를 만들어 주었습니다
처음 로그인시 이 토큰값을 파싱하여 사용할 수 있도록 말이죠!
public ObjectMapper objectMapper = new ObjectMapper();
또하나의 핵심은 objectMapper 입니다. 열심히 만들어 놓은 request Dto 객체를 Map.class 로 변환해주는 고마운 친구입니다
// given
String _url = Host +url;
HttpHeaders httpHeaders = baseUtility.getDefaultHeader(Token);
// when
HttpEntity request = new HttpEntity(httpHeaders);
ResponseEntity response = restTemplate.exchange(
_url,
HttpMethod.GET,
request,
String.class);
// then
assertEquals(HttpStatus.OK ,response.getStatusCode());
기본적인 GET 방식 테스트 구조입니다.
// given
String _url = Host+url;
HttpHeaders httpHeaders = baseUtility.getDefaultHeader(Token);
SetNodeConfigRequest request = SetNodeConfigRequest.builder()
.description("")
.build();
Map<String, String> body = validateVariables(objectMapper.convertValue(request, Map.class));
body = ifEmptyDontAdd(body);
// when
HttpEntity requestMessage = new HttpEntity(body,httpHeaders);
ResponseEntity response = restTemplate.exchange(
_url,
HttpMethod.PUT,
requestMessage,
String.class
);
// then
JSONObject jsonObject = new JSONObject(response.getBody());
String res = (String) jsonObject.get("data");
assertEquals(HttpStatus.OK ,response.getStatusCode());
assertNotEquals(null,res);
기본적인 PUT, POST 방식 테스트 구조입니다
확인 결과 status 가 200인 경우에도 Message나 data 의 값이 없는 경우가 있어 assertNotEquals 를 통해 null 인지 검사합니다
- 우선 getDefaultHeader를 통하여 토큰값을 이용한 헤더를 생성합니다
- 빌더를 이용하여 해당하는 DTO 를 생성하고 Map으로 매핑한뒤 Validation을 거칩니다.
- request 로는 HttpEntity를 활용하였으며 response엔티티를 통해 결과값을 String 의 형태로 반환받습니다
- 이때 RestTemplate은 사전에 Config로 설정한 SSL을 우회하는 템플릿입니다
- assertEquals 를 이용하여 테스트한 결과값이 예상했던 결과값인지 확인합니다
이 뒤의 포스팅에서는 Window System을 이용하여 스프링부트를 시작하고 이를 리버스 프록시로 설정하는 것에 대하여 포스팅할 예정입니다