Spring Boot 를 IIS 서버에 배포해보자 [1]

JungWooLee·2022년 7월 4일
0

스프링 프로젝트

목록 보기
1/2

시작에 앞서

목적 ?

  • 얼마전 VDI 시스템 프로젝트를 진행하면서 공부하게 되었던 내용을 기록합니다
  • 현재 실무에서는 AWS가 아닌 IIS 를 통해 서버에 배포를 하고 있는데 처음 공부하게 되는 내용이라 개인적으로 저장하고 싶었습니다
  • RESTful API 에서 Spring Doc , TDD를 이용한 방식을 채택하여 API 를 테스트하고 HTTP 와 프로토콜들과 친숙해지고 싶은 목적성을 가집니다

기본 설정

  1. Spring Boot 기반으로 설계하여 request 와 얻게되는 response를 구분하여 설계를 시작합니다

  2. Proxmox라는 VDI 시스템은 기본적으로 Rest 방식을 이용하여 API 처럼 사용됩니다. 시작하기 앞서 공식 문서를 보며 request에 관련된 설계를 합니다

    https://pve.proxmox.com/pve-docs/api-viewer/index.html#/nodes

  3. 이미 짜여진 틀에 맞추어 API 공식 문서를 토대로 API 테스트와 분석을 시작합니다


VDI 란?

1. 우선 VDI 라는 개념에 대해 큰틀에서 이해해보려 노력하였습니다.

결과물의 도메인 조차 이해하지 못한다면 완성도는 기대하기 어렵기 때문이죠
제가 이해한 바는 아래와 같습니다

VDI 란 가상 데스크톱 인프라이며 전체적인 기반은 "인프라"가 핵심입니다.
회사에서 사용되는 회사 자체적인 기술적인 정보를 은닉하고 기밀성을 갖출 수 있게 하는 시스템입니다. VDI에서는 하이퍼바이저가 서버를 가상 머신으로 세분화하고, 가상 머신은 가상 데스크톱을 호스팅하며, 사용자는 각자의 기기를 통해 가상 데스크톱에 원격으로 액세스합니다.

이러한 시스템이 있기 전에는 물리적인 분리 = 컴퓨터를 N 대로 관리 하는 방식을 채택하여 각 컴퓨터에는 권한을 부여하여 각 사용에 맞추어 사용하고는 했었습니다.
하지만 이러한 관리는 사람이 직접적으로 개입하여 관리해야 할 뿐만아니라 비용적인 측면에서도 두개의 하드웨어를 사용하여야 한다는 점에서 불편함이 많았죠.

이러한 점을 개선하여 등장한 것이 VDI 입니다. 앞서 설명한것과 같이 가상의 머신 (Virtual Machine) 을 필요에 따라 요청하여 사용할 수 있으며 이에 Access 할 수 있는 권한 또한 컴퓨터를 통하여 설정이 가능하게 되었죠.

VDI 이점 : 원격 액세스, 비용 절감, 보안, 중앙 집중식 관리
이외에도 원격작업, Bring your Own Device, 반복 작업 또는 교대 작업에 이점을 가집니다

이렇게만 보면 너무나 완벽한 시스템이지만 왜 모든 회사에서 사용하고 있지 않는지 의문점이 들테지만.. 우선 인프라를 다룬다고 하여 VDI 시스템을 완벽히 다루는 것은 아니며 이에 정통하는 전문가가 따로 존재하며 각 VDI를 제공하는 업체마다 고유한 기능, 사용법을 가지기에 접근성이 다소 떨어집니다.

뿐만 아니라, 초기 설치 비용또한 만만치 않아 규모가 작은 경우 비용 절감의 효과보다는 유지 비용이 더 나올 수 있다는 마법같은 일이 일어납니다.

여기까지 이해했을때 그렇다면 도커(Docker) 와는 무엇이 다른것인가 의문점을 가지게 되었죠

2. VM 과 도커의 차이

가상화 이전에는 하나의 서버에 하나의 어플리케이션만 구동시키는 것이 일반적이였습니다. 그렇기에 하나의 서버에 남는 자원이 많았고 전체적으로 비효율적이였죠.

도커와 VM 을 이 틀에서 보면 같은 불편함을 해소 합니다

큰 차이점은 게스트 OS의 유무입니다. VDI의 VM에서는 Guest OS가 설치되는데 도커에는 그렇지 않습니다. 이는 자원의 효율성에서 큰 차이를 띄게 되는데 VM 을 사용하게 될때 하나씩 늘릴때 마다 OS 자원을 할당하여야 하지만 도커는 구동하는데 필요한 패키지만 있다면 컨테이너를 구동할 수 있습니다.


Spring Boot & PostMan

1. Spring Boot 설정

  • IntelliJ 의 Spring initializr 를 통하여 초기 dependency들을 설정합니다
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 라는 것을 처음으로 알게 되어 적용하게 되었습니다

2. 전체 틀 설정

  • 테스트를 위해서도 response 는 몰라도 request model은 필수적인 요소라 이부분은 시작하기 전에 미리 만들어 두었습니다
  • 모든 로그인 시스템과 마찬가지로 ticket, token 에 대한 관찰을 위해 Fiddler 를 통하여 로그인 과정에서 오가는 패킷들을 분석하였는데 이 proxmox의 경우 티켓을 이용한 방식이었습니다
  • 쿠키를 통하여 티켓을 들고 다니는데 정확한 세션 타임은 분석하지 못하였지만 못해도 20분 이상은 지속되는 것을 확인 하였습니다

이제 POSTMan 을 통하여 테스트를 진행합니다

3. PostMan

이후 문서화를 위하여 Collection으로 하나 팠습니다!

proxmox의 경우 Admin 사용자의 권한으로 지정한 IP 에 권한을 주는 방식이었는데 인증서 대체 url 과 타겟 url이 일치하지 않아 SSL 인증에 다소 어려움이 있었습니다.

인증서 다운로드 이후 환경변수로 해당 인증서의 PATH를 지정하면 풀 수 있다고 알고 있었는데 많은 시행착오에도 인증이 되지 않아 우회하는 방식을 선택하게 되었습니다

PostMan 에는 이를 disable하는 기능이 있어 다행히도 테스트에는 큰 지장이 없었습니다

  • 로그인 부분 : (Before Each) 모든 테스트에 앞서 티켓이 없으면 api를 테스트할 수 없기에 티켓값을 얻어 파싱한뒤 환경변수로 지정하는 것이 시작이었습니다

  • 처음 알게 되었는데 PostMan 에는 Tests를 통하여 javaScript를 실행할 수 있어 간편하게 저장할 수 있었습니다. (공부하는것을 기록하고자 하는 목적이기 때문에 따로 정확한 코드는 제공하지 않습니다)

  • 이를 이용하여 global 로 설정하여 Cookie에 이 토큰값을 들고 다닐 수 있도록 설정하게 되었습니다. 헤더의 preset 으로 지정하여 각 기능별로 추가하기 용이하도록 하였습니다

  • 모든 기능상의 문제가 발견되지 않아 다음 단계로 넘어갔습니다

4. DTO, RestTemplateConfig


우선 DTO입니다

  • 롬복을 사용하여 보일러 플레이트 코드를 최소화 하였으며 민감정보가 없다면 ToString 까지 추가하여 중간 중간 디버깅에도 용이하도록 하였습니다
  • 빌더같은 경우 생성자를 통한 객체 생성보다 직관적이고 실수를 줄일 수 있다 판단되어 테스트하기에 용이하도록 만들어 주었습니다

다음은 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 가 되기전 변환해주는 메서드입니다


5. JUnitTest

본격적인 테스트를 시작합니다

@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 인지 검사합니다

  1. 우선 getDefaultHeader를 통하여 토큰값을 이용한 헤더를 생성합니다
  2. 빌더를 이용하여 해당하는 DTO 를 생성하고 Map으로 매핑한뒤 Validation을 거칩니다.
  3. request 로는 HttpEntity를 활용하였으며 response엔티티를 통해 결과값을 String 의 형태로 반환받습니다
  4. 이때 RestTemplate은 사전에 Config로 설정한 SSL을 우회하는 템플릿입니다
  5. assertEquals 를 이용하여 테스트한 결과값이 예상했던 결과값인지 확인합니다

이 뒤의 포스팅에서는 Window System을 이용하여 스프링부트를 시작하고 이를 리버스 프록시로 설정하는 것에 대하여 포스팅할 예정입니다

0개의 댓글