'Java'에 해당하는 글 23건

Apache POI 는 MS office 문서포맷을 Java 로 사용할 수 있는 API 를 제공한다.

보통 WorkBook 으로 문서를 만드는 예제나, 암호화된 문서의 암호를 삭제시킬 수 있는 예제들이 떠돌고 있다.

 

MultipartFile 로 엑셀파일 전달 받고, 해당 파일에 암호 설정해서 돌려주는 API 를 만들어 봤다.

 

 

 Gradle 

 

dependencies {
    implementation 'org.apache.poi:poi:5.2.2'
    implementation 'org.apache.poi:poi-ooxml:5.2.2'
}

 

 

 Java 

 

@PostMapping("/excel/encrypt")
public ResponseEntity<ByteArrayResource> excelEncrypt(@RequestParam MultipartFile file) {

    String[] extensionArr = {"xls", "xlsx"};  // 97~, 2007~
    String[] mimeTypeArr = {"application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"};
    // 파일검증 생략

    // apache poi 엑셀 암호화
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    try (POIFSFileSystem fs = new POIFSFileSystem()) {
        EncryptionInfo encryptionInfo = new EncryptionInfo(EncryptionMode.agile);

        Encryptor encryptor = encryptionInfo.getEncryptor();
        encryptor.confirmPassword("password!@#$");

        try (OPCPackage opc = OPCPackage.open(file.getInputStream());
             OutputStream outputStream = encryptor.getDataStream(fs)) {
            opc.save(outputStream);
        }
        fs.writeFilesystem(byteArrayOutputStream);
        byteArrayOutputStream.close();
    } catch (Exception e) {
        logger.info(e.getMessage());
    }

    HttpHeaders header = new HttpHeaders();
    String encFileName = URLEncoder.encode(file.getOriginalFilename(), StandardCharsets.UTF_8).replace("+", "%20");
    header.setContentType(MediaType.parseMediaType(file.getContentType())); // <- 지정하지 않아도 무관
    header.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=utf-8''enc_" + encFileName);

    return new ResponseEntity<>(new ByteArrayResource(byteArrayOutputStream.toByteArray()),
                    header, HttpStatus.OK);
}

 

POIFS (Poor Obfuscation Implementation File System)
 - MS Office 의 OLE 2 Compound document 파일 포맷을 읽고 쓰는 컴포넌트. 
 - 모든 오피스 파일 포맷은 OLE2 방식이므로 하위 모든 컴포넌트의 기반이 된다.
HSSF (Horrible SpreadSheet Format)
 - MS excel 97 이후로 현재까지의 엑셀파일들에 읽고 쓰기를 지원하는 컴포넌트.
XSSF (XML SpreadSheet Format)
 - MS excel 2007 이후의 오픈 XML 파일 포맷인 *.xlsx 파일을 읽고 쓰는 컴포넌트.

 

 

 

 

음~ 잘된당~

용량을 10M 정도 넘겨봤더니, 에러 발생. 늘려보려 했는데... 실패!

 

Tried to allocate an array of length 132,636,469, but the maximum length for this record type is 100,000,000.
If the file is not corrupt or large, please open an issue on bugzilla to request 
increasing the maximum allowable size for this record type.
As a temporary workaround, consider setting a higher override value with IOUtils.setByteArrayMaxOverride()

WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

CORS error

Programming/Spring Boot 2022. 3. 10. 21:13

CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 특정 사이트(A.com) 로부터 다른 사이트(B.com) 로의 요청 및 접근을 가능하게 하는 프로토콜이다. 보안 상 최신 브라우저는 기본적으로 타 사이트로의 요청을 제한하고 있다. SPA 프레임워크의 사용 등으로 frontend 와 backend 의 분리가 일반적이 된 요즘, 서버로의 API 요청시 프로토콜/도메인/포트 등이 다르다면, CORS 설정이 올바르지 않다면 브라우저 콘솔에서 관련 error 를 마주하게 된다.

 

Access to XMLHttpRequest at 'http://localhost:8080/account/signin' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

 

기본적인 CORS 요청들을 단순(simple) 요청이라 할 때, application/json 타입의 요청이거나 커스텀 헤더를 전송하는 등의 요청은 사전(preflight) 요청이라고 한다. 이 경우는 웹 브라우저에서 OPTIONS 메소드로 preflight(사전전달) 요청이 먼저 수행하고, 서버에서 허용 범주를 응답 받은 후에 실제 GET/POST 등의 요청을 보내게 된다.

 

preflight

 

 

CORS 서버 설정

 

서버 개발자는 요청을 허용할 범주(Access-Control-Allow-* header/method/origin) 를 설정하여 응답 헤더에 보낼 수 있도록 세팅하면 된다. Spring Boot 의 경우, WebMvcConfigurer 를 구현하고 addCorsMappings 메소드를 오버라이드 하여 아래와 같이 설정할 수 있다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000", "http://localhost:8081")
                .exposedHeaders("Authorization");
    }
}

 

exposedHeaders 메소드의 경우 브라우저에서 클라이언트가 접근 가능한 응답 헤더 목록을 설정한다. (Authorization 은 token 등을 이용할 때 많이 사용) cors mapping 만 설정했을 때는 GET, HEAD, POST 의 메소드에 대해서, 모든 출처의 요청에 대해 허용하는 것이 기본값이다. 위와 같이 Spring 에서 설정했을 경우, 아래와 같은 응답 헤더를 보내주고 CORS 는 성공하게 된다.

 

Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET,HEAD,POST
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Expose-Headers: Authorization

 

컨트롤러, 메소드 단위에서 @CrossOrigin 어노테이션을 사용할 수도 있다.

 

 

또한, Spring Security 를 사용중이라면 아래와 유사한 방식으로 cors 요청을 처리할 수 있다.

 

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .and()
            .cors()
            .and()...
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.addAllowedOrigin("http://localhost:3000");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

Spring Boot 에서 모든 원본 Http request 와 response 에 대한 log 를 기록하려고 한다.

 

요청 → Filter → Interceptor → AOP → Interceptor → Filter → 응답 의 처리 순서로 볼 때, 가장 원시적인 요청 본문과 응답 본문을 캐치할 수 있는 부분은 Filter 였다. 요청을 받자마자 Filter 에서 본문을 로깅하고, 응답 직전에 본문을 로깅하고... 하지만 이거슨 순수한 나만의 착각?

 

 

spring-mvc-request-lifecycle

 

 

Request / Response body 로깅 목표와... 결과

  • 비정상 요청 파악 및 비정상 응답 파악하기.
  • 최대한 원시 본문을 유지할 것. (의미를 찾지못함;)
  • 요청 본문과 응답 본문의 로깅을 각각 처리. (막상 분리하고 나니 굳이...)
  • POST 메소드만 처리.
  • form-urlencoded 와 application/json 모두 처리.
  • 로깅 위치는 Filter 와 Interceptor 의 preHandle 과 afterCompletion 를 적절히 사용.

 


 

1. request / response 본문(body) 읽기

 

request 와 response 의 본문(body) 를 로깅하기 위해 HttpServletRequest 의 getReader() 나 getInputStream() 등을 통해서 읽어낼 경우, 다음 단계에서 이 본문들은 비어있는 상태가 된다. 인터셉터에서 request 본문을 읽는다면 컨트롤러에 파라미터가 전달되지 않을 것이고, 인터셉터에서 response 본문을 읽는다면 클라이언트에 내용이 빈 상태로 응답될 수 있다. 이를 방지하기 위해 사용하는 방법이 여러가지 있는데 난 그 중에서 ContentCachingRequestWrapper / ContentCachingResponseWrapper 클래스를 선택했다.

 

 

2. ContentCachingRequestWrapper / ContentCachingResponseWrapper

 

ContentCachingRequestWrapper 는 getReader() 나 getInputStream() 등을 통해서 읽은 요청 본문을 캐시하고 byte array 로 이를 읽을 수 있게 하는 HttpServletRequest 래퍼이다. ContentCachingResponseWrapper의 경우 Body를 돌려주기 위해 copyBodyToResponse method를 호출해줘야 한다.

 

아래는 filter 파일 하나에 전부 때려넣은 예제이다.

 

@Component
public class LoggingFilter implements Filter {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws IOException, ServletException {

        if (servletRequest instanceof HttpServletRequest && servletResponse instanceof HttpServletResponse) {
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            HttpServletResponse response = (HttpServletResponse) servletResponse;

            HttpServletRequest requestToCache = new ContentCachingRequestWrapper(request);
            HttpServletResponse responseToCache = new ContentCachingResponseWrapper(response);

            chain.doFilter(requestToCache, responseToCache);

            logger.info("request header: {}", getHeaders(requestToCache));
            logger.info("request body: {}", getRequestBody((ContentCachingRequestWrapper) requestToCache));
            logger.info("response body: {}", getResponseBody(responseToCache));
           
        } else {
            chain.doFilter(servletRequest, servletResponse);
        }
    }

    private Map<String, Object> getHeaders(HttpServletRequest request) {
        Map<String, Object> headerMap = new HashMap<>();

        Enumeration<String> headerArray = request.getHeaderNames();
        while (headerArray.hasMoreElements()) {
            String headerName = headerArray.nextElement();
            headerMap.put(headerName, request.getHeader(headerName));
        }
        return headerMap;
    }

    private String getRequestBody(ContentCachingRequestWrapper request) {
        ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
        if (wrapper != null) {
            byte[] buf = wrapper.getContentAsByteArray();
            if (buf.length > 0) {
                try {
                    return new String(buf, 0, buf.length, wrapper.getCharacterEncoding());
                } catch (UnsupportedEncodingException e) {
                    return " - ";
                }
            }
        }
        return " - ";
    }

    private String getResponseBody(final HttpServletResponse response) throws IOException {
        String payload = null;
        ContentCachingResponseWrapper wrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
        if (wrapper != null) {
            wrapper.setCharacterEncoding("UTF-8");
            byte[] buf = wrapper.getContentAsByteArray();
            if (buf.length > 0) {
                payload = new String(buf, 0, buf.length, wrapper.getCharacterEncoding());
                wrapper.copyBodyToResponse();
            }
        }
        return null == payload ? " - " : payload;
    }
}


/*
2022-03-01 01:37:04.049  INFO 16344 --- [nio-8080-exec-6] com.example.api.filter.LoggingFilter     : request header: {content-length=51, postman-token=d4e2bfa4-1...
2022-03-01 01:37:04.050  INFO 16344 --- [nio-8080-exec-6] com.example.api.filter.LoggingFilter     : request body: {
    "param1": "test1",
    "param2": "test2"
}
2022-03-01 01:37:04.051  INFO 16344 --- [nio-8080-exec-6] com.example.api.filter.LoggingFilter     : response body: {"res1":"test1","res2":"test2","res3":"test3"}
*/

 

Filter 에서 ContentCachingRequestWrapper / ContentCachingResponseWrapper 래퍼로 request 와 response 를 캐시하여, 재사용이 가능하도록 하였다. 밑에 메소드 3개는 각각 헤더, 요청 본문, 응답 본문 가져오기.

 

 

이렇게 ContentCaching* 클래스로 간단하게 요청/응답 본문을 로깅할 수 있었다. 심플한건 좋았지만, 내가 원했던 방법은 이렇게 filter 에 전부 때려박는 것은 아니었다. 요청 본문은 preHandle 에서 로깅하고 싶었는데, 제약 조건이 많았다. preHandle 에서 ContentCachingRequestWrapper 로는 json 을 처리하지 못했다. 하지만 form-urlencoded 요청의 경우, application.properties 에서 logging.level.org.springframework.web=debug 설정이 되어 있다면, 인터셉터 preHandle 에서도 요청 본문 로깅이 가능했다. Exception 발생시 응답 본문은 null 이 발생했으나, 이 경우 상태 코드로 응답 예측이 가능하니 큰 문제는 없었다. multipart/form-data 요청은 용량 차지하지 않게 별도로 알아서 잘 처리하시고... (난 그냥 본문 날려버렸음.)

 

굳이 Spring 에서 처리하긴 했지만, 로깅을 커스텀 하는 것은 쉽지 않았다. 어떤 서비스에 어떤 목적으로 어디에 로깅을 하느냐 또 그 로깅을 어떻게 활용할 것인가가 중요한 것 같다.

 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

Spring API 프로젝트에서 공통적인 에러 코드로 응답하기.

 

API 개발시 어떠한 처리 결과에도 동일한 포맷의 응답(response) 을 리턴하는 것은 중요하다. 아래는 가장 간단한 예이다.

 

{
    "code": 200,
    "message": "ok",
    "result": {}
}

 

code / message 로 미리 정의된 응답 코드와 메시지를 확인할 수 있고, 정상적으로 처리가 되었을 경우 result 에서 결과를 확인할 수 있다. 디테일이 필요하다면 더 다양한 코드에 상세한 설명을 넣으면 되고, 보안을 강화한다면 메시지를 없앨 수도 있다. 이러한 공통적인 포맷을 Spring 에서 구현하는 방법을 살펴 보았다.

 

 

1. whitelabel 에러 페이지

 

Spring Boot 는 기본적으로 text/html 타입의 요청에 대해 whitelabel 이라는 오류 페이지를 제공한다. 기존 톰캣의 보라색 에러 페이지 보다는 괜찮아 보일 수 있다.

 

 

whitelabel-spring

 

브라우저에서 접속했을 때에도 에러 페이지를 커스텀 하려면 properties 파일에서 whitelabel 페이지를 비활성화 하고 에러 페이지를 재정의해야 한다.

 

# application.properties
server.error.whitelabel.enabled=false

 

 

2. 기본 json 에러 응답

 

Spring Boot 는 기본적으로 RESTful 이나 application/json 타입의 요청에 대해 basicErrorController 로부터 아래와 같은 포맷으로 응답한다.

 

{
    "timestamp": "2022-02-15T04:28:46.310+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/a"
}

 

위 속성은 DefaultErrorAttributes 에서 정의되는데 속성만 추가하려면 application.properties 에서 server.error 옵션들을 수정하던지 DefaultErrorAttributes 를 상속하여 커스텀 할 수도 있다.

 

# message 항목 보이기
server.error.include-message=always

 

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {

    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> result = super.getErrorAttributes(webRequest, options);
        result.put("description", "abcdefg");
        return result;
    }
}

 

 

3. AbstractErrorController 상속하여 재정의

 

basicErrorController 처럼 ErrorController 인터페이스를 구현한 AbstractErrorController 를 상속하여 에러 응답을 재정의할 수 있다.

 

@RestController
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {

    public CustomErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes);
    }

    @RequestMapping
    public ResponseEntity<ErrorMessageEntity> customError(HttpServletRequest request) {
        HttpStatus httpStatus = getStatus(request);

        CustomResponseEntity customResponseEntity = new CustomResponseEntity();
        customResponseEntity.setCode(httpStatus.value());
        customResponseEntity.setMessgae(httpStatus.getReasonPhrase());
        customResponseEntity.setResult(null);

        return new ResponseEntity<>(customResponseEntity, httpStatus);
    }
}

 

@Entity
public class CustomResponseEntity {
    private Integer code;
    private String messgae;
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String result;
    ...
}

 

기본 에러 경로인 /error (properties 파일: server.error.path) 는 basicErrorController 에서 매핑을 시도하므로, ErrorController 인터페이스를 구현하지 않고 /error 컨트롤러를 사용한다면 런타임 에러가 발생한다.

 

Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'basicErrorController' method

 

 

4. ResponseStatusException 사용

 

위와 같이 AbstractErrorController 를 상속하면 모든 HTTP 상태 코드에 대한 응답을 커스텀 할 수 있다. 여기에 Spring 5+ 를 사용 중이라면 RuntimeException 을 상속하는 ResponseStatusException 를 이용해 어디에서든 원하는 에러를 발생시킬 수 있다.

 

throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid path");

 

특정 상황에 특정 에러 코드와 메시지로 응답하고 싶었는데 2번째 파라미터를 응답 엔티티에 넣는 것이 쉽지 않았다.

 

 

5. Custom Exception 생성

 

ResponseStatusException 을 대신하여 메시지도 응답할 수 있도록 CustomException 을 생성한다.

 

@Component
public class CustomException extends RuntimeException {

    private ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return this.errorCode;
    }
}

 

에러 메시지를 enum 으로 정리한다.

 

public enum ErrorCode {
    
    EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired Token"),
    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid Token"),
    SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    ...
    ;

    private final HttpStatus status;
    private final String message;

    ErrorCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }

    public HttpStatus getStatus() {
        return this.status;
    }

    public String getMessage() {
        return this.message;
    }
}

 

컨트롤러 전역에서 에러를 핸들링할 수 있도록 @ControllerAdvice, @ExceptionHandler 를 작성한다.

 

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<CustomResponseEntity> myCustomException(CustomException ex) {
        CustomResponseEntity customResponseEntity = new CustomResponseEntity();

        ErrorCode errorCode = ex.getErrorCode();
        customResponseEntity.setCode = errorCode.getStatus().value();
        customResponseEntity.setMessage = errorCode.getMessage();

        return new ResponseEntity<>(customResponseEntity, errorCode.getStatus());
    }
}

 

특정 코드에 에러 발생시키기.

 

throw new CustomException(EXPIRED_TOKEN);

// output
{
    "code": 401,
    "message": "Expired Token"
}

 

 

6. 404 error 핸들러 추가

 

여기까지 하면 404 에러 발생시 에러 핸들러를 찾을 수 없다는 500 에러가 발생하게 되는데, dispatcher servlet 에서 요청을 처리할 수 없는 핸들러가 생긴다면 NoHandlerFoundException 예외를 발생시키도록 아래와 같이 수정한다.

 

# application.yaml
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  web:
    resources:
      add-mappings: false

 

위 5번 GlobalExceptionHandler 에 아래 NoHandlerFoundException 추가하여 NoHandlerFoundException 예외를 처리한다.

 

@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<DefaultMessageEntity> handle404(NoHandlerFoundException ex) {
    CustomResponseEntity customResponseEntity = new CustomResponseEntity();
    customResponseEntity.setCode = HttpStatus.NOT_FOUND.value();
    customResponseEntity.setMessage = HttpStatus.NOT_FOUND.getReasonPhrase()

    return new ResponseEntity<>(customResponseEntity, HttpStatus.OK);
}

 

 

 

CustomException(EXPIRED_TOKEN) 이라는 에러를 발생시키면, @ExceptionHandler 에서 해당 예외를 처리하는 예제이다. CustomException 을 제외한 예외들은 3번에서 작성한 CustomErrorController 에 의해 처리될 것이다. 3번 5번 6번 조합!

 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

AWS CDK

Server/AWS 2022. 2. 13. 23:54

AWS CDK(Cloud Development Kit) 를 이용하면 Java, Python, Script 등을 통해 AWS CloudFormation 을 만들어 AWS 클라우드 리소스를 프로비저닝 할 수 있다. AWS 콘솔 창에서 클릭과 타이핑으로 생성하던 리소스들을 code 화 하여, 반복적인 리소스 생성이 필요할 때 유용하다.

 

쉽게 말해 CDK 에서 지원하는 언어(Java, Python, TypeScript, ...) 로 프로그래밍하고, 커맨드 라인 도구인 CDK 툴킷을 통해 AWS CloudFormation 템플릿을 생성하고, stack 을 배포하여 AWS 의 모든 리소스들을 생성/설정할 수 있다.

 

준비해야 할 것은..

  • AWS cli 설치
  • 자격증명(credentials)
  • cdk 설치...
    > npm install -g aws-cdk@1.140.0

 

현재 CDK 는 v2 버전대가 있지만, 이런저런 이유으로 인하여 v1 을 사용하였다. 차이라면 v2 는 새로나온 리소스등을 계속해서 추가해 줄 것이고, v1 은 점점 deprecated 되는 메소드들이 많아질 것이다. 또 bootstrap 도 v2 에서는 필수로 설치하는 거 같고... 참고로 AWS 는 2023년 6월 1일에 CDK v1 에 대한 지원을 종료한다.

 

준비를 마쳤다면  Java(JDK 8+) 로 S3 버킷을 하나 생성하는 간단한 단계는 다음과 같다.

 

 

1. cdk 프로젝트 자동 생성

 

> cdk init app --language java

 

위 명령은 현재 디렉토리에서 cdk 를 쉽게 개발할 수 있도록 관련 library 들의 설정이 포함된 maven 기반의 빈 프로젝트를 생성해 준다. IDE 로 열어보면 Java 기본 구조로 된 maven 프로젝트를 볼 수 있다.

 

 

2. dependency 추가

 

<dependency>
    <groupId>software.amazon.awscdk</groupId>
    <artifactId>s3</artifactId>
    <version>${cdk.version}</version>
</dependency>

 

v2 는 대부분의 리소스를 기본적으로 지원하는 거 같긴한데... 필요하다면 추가할 artifactId 는 대부분의 AWS 서비스 이름으로 검색하고 dependency 에 추가해야 한다.

 

 

3. java 파일 확인/수정/빌드

 

main 메소드가 들어있는 App.java 파일과, 기본적인 Stack.java 파일이 생성되어 있다. App 에는 하나 이상의 Stack(구체적인 AWS 리소스) 을 정의할 수 있다. Stack 파일을 바탕으로 리소스를 추가하면 되고, main 메소드에는 계정 정보와 함께 새로 생성한 Stack 파일을 로드하면 된다.

 

// App.java
public class App {
    public static void main(final String[] args) {
        App app = new App();

        new MyS3Stack(app, "MyCdkS3Stack", StackProps.builder()
                .env(Environment.builder()
                        .account("111122223333")
                        .region("ap-northeast-2")
                        .build())
                build());

        app.synth();
    }
}

 

// MyS3Stack
public class MyS3Stack extends Stack {
    public MyS3Stack(final Construct scope, final String id) {
        this(scope, id, null);
    }

    public MyS3Stack(final Construct scope, final String id, final StackProps props) {
        super(scope, id, props);

        Bucket.Builder.create(this, "MyFirstBucket")
                .bucketName("cdk-test1")
                .build();
    }
}

 

Stack 의 모든 리소스는 scope, id, props 등으로 구성되는데, scope 는 부모 stack 을 명시한다. id 는 App 내부에서 사용될 id(hash 문자열이 붙음). props 는 해당 리소스의 속성이다.(null 가능) 위 예제에서는 각각 this, MyfirstBucket, bucketName 등이 되겠다. Java에서는 props를 전달하기 위해 Builder가 제공된다. 위 예에서는 BucketProps 나 Bucket 을 사용할 수 있다.

 

// Bucket
Bucket.Builder.create(this, "MyFirstBucket")
    .bucketName("cdk-test1")
    .build();

// BucketProps
new Bucket(this, "MyFirstBucket", new BucketProps.Builder()
    .bucketName("cdk-test1")
    .build());

 

 

4. CDK 병합(synth)

 

코드를 모두 작성했다면 App 의 루트 디렉토리에서 synth / deploy 명령으로 병합/배포해야 한다. cdk synth 를 실행하면 자동으로 빌드(mvn package) 도 되지만, 수동으로 빌드하여 미리 에러 등을 체크할 수 있다. 마찬가지로 deploy 역시 synth 를 자동으로 실행 해주기 때문에 사실상 빌드없이 deploy 만 해도 무방하긴 하다.

 

> cdk synth --profile my
Resources:
  MyFirstBucketB8881111:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: cdk-test1
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Metadata:
      aws:cdk:path: MyS3Stack/MyFirstBucket/Resource
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Analytics: v2:deflate64:H5sIAAAAAAAA/yWKQQ5EIAwA3+Idq1UfYNbbHvUFpNYEiZBQ0APh70Y8zSQzCNgN0FajvqSm1TaZfGDIS9Rk1eSdxJAoqmlzM4tPgbio55Ue8i+R4bd9Vor651M3iNAC9tUuxtQhuWgOhvnjDSNKfjhxAAAA
    Metadata:
      aws:cdk:path: MyS3Stack/CDKMetadata/Default

 

위와 비슷한 yaml 형식으로 출력되며 cdk.out/MyS3Stack.template.json 파일에 저장되고, 배포시 cdk.out 디렉토리를 기반으로 배포된다.

aws-cli 에 profile 이 여러개일 경우 원하는 profile 로 지정해야 한다. 소스와 aws-cli 의 계정이 일치하지 않는 경우 아래와 같은 에러가 발생한다.

 

Need to perform AWS calls for account 111122223333, but the current credentials are for 444455556666

 

위 오류가 발생해서 profile 을 정상적으로 설정했는데 아래 오류가 또 발생했다.

 

MyCdkS3Stack (MyFirstBucketB8881111) cdk-test1 already exists
The stack named KpsCdkS3Stack failed creation, it may need to be manually deleted from the AWS console: ROLLBACK_COMPLETE

 

S3 를 확인해보니 비어 있는데 웬 cdk-test1 already exists... 처음 profile 오류나면서 잘못된 계정으로 cdk-test1 을 생성하려는 시도가 있었을 것이고, 그 정보가 어딘가에 남아 있는듯...; (이해할 수 없음)

 

 

5. CDK 배포(destroy)

 

deploy 로 배포하고 나면, CloudFormation 과 S3 에서 리소스들을 확인할 수 있다.

 

> cdk deploy --profile my
MyS3Stack: deploying...
MyS3Stack: creating CloudFormation changeset...
  0/3 |오후 12:12:13 | REVIEW_IN_PROGRESS   | AWS::CloudFormation::Stack | MyS3Stack User Initiated
  0/3 |오후 12:12:18 | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack | MyS3Stack User Initiated
  0/3 |오후 12:12:22 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata/Default (CDKMetadata)
  0/3 |오후 12:12:22 | CREATE_IN_PROGRESS   | AWS::S3::Bucket    | MyFirstBucket (MyFirstBucketB8881111)
  0/3 |오후 12:12:24 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata/Default (CDKMetadata) Resource creation Initiated
  0/3 |오후 12:12:24 | CREATE_IN_PROGRESS   | AWS::S3::Bucket    | MyFirstBucket (MyFirstBucketB8881111) Resource creation Initiated
  1/3 |오후 12:12:24 | CREATE_COMPLETE      | AWS::CDK::Metadata | CDKMetadata/Default (CDKMetadata)
  2/3 |오후 12:12:45 | CREATE_COMPLETE      | AWS::S3::Bucket    | MyFirstBucket (MyFirstBucketB8881111)
  3/3 |오후 12:12:46 | CREATE_COMPLETE      | AWS::CloudFormation::Stack | MyS3Stack

 ✅  KpsCdkS3Stack

Stack ARN:
arn:aws:cloudformation:ap-northeast-2:111122223333:stack/MyS3Stack/1231dca0-8a2f-12ec-8c62-0618df8ff132

 

cloudformation-cdk

 

s3-cdk

 

여러 스택을 동시 실행하는 것도 가능하다.

 

> cdk deploy Happy Grumpy   # app defines two or more stacks; two are deployed
> cdk synth "Stack?"    # Stack1, StackA, etc.
> cdk deploy "*Stack"   # PipeStack, LambdaStack, etc.

 

 

6. CDK 재배포

 

> cdk diff --profile my

 

위 명령으로 기존 소스와 수정된 소스간의 변경점을 출력해 볼 수 있으며, cdk deploy 로 계속해서 코드의 변경사항을 리소스에 적용할 수 있다. (버전 관리가 유용할 수 있다.)

 

 

7. stack 삭제

 

생성한 stack 을 삭제한다.

 

> cdk ls
MyS3Stack

> cdk destroy MyS3Stack --profile my
Are you sure you want to delete: MyS3Stack (y/n)? y
MyS3Stack: destroying...

 ✅  MyS3Stack: destroyed

 

CloudFormation 의 Stack 은 정상적으로 삭제되었지만, S3 버킷은 삭제되지 않았다. 기본적으로 사용자 데이터가 포함될 수 있는 리소스에는 RETAIN 의 removePolicy 속성이 있으며, 해당 리소스는 생성될 때 스택과 분리되므로 수동으로 삭제해야 한다. Stack 삭제시 비어있는 버킷을 삭제하고자 한다면 removePolicy 설정을 Destroy 변경하면 된다. 또한 버킷이 비어있지 않다면 이 또한 실패하는데, 버킷의 autoDeleteObjects 속성을 true로 설정할 수 있다.

 

.removalPolicy(RemovalPolicy.DESTROY)
.autoDeleteObjects(true)

 

코드 변경하며 여러 버킷을 만들었었는데 마지막 소스에만 국한하지 않고, 모든 버킷을 삭제한 건 조금 굿잡...

 

 

Java 의 더 많은 예제는 요기...
https://github.com/aws-samples/aws-cdk-examples/tree/master/java

 

GitHub - aws-samples/aws-cdk-examples: Example projects using the AWS CDK

Example projects using the AWS CDK. Contribute to aws-samples/aws-cdk-examples development by creating an account on GitHub.

github.com

 

 

약 3일 정도 CDK 를 사용해 봤다. 위에 s3 의 예는 약 100개 이상의 리소스 중에 하나일 뿐이다. API 꾸역꾸역 찾아가며 각종 리소스를 생성해 봤다. Terraform 이든 CDK 든 당연히 누군가에게는 좋은 IaC 도구이다. 본인이 얼마나 많은 리소스를 관리해야 할지에 따라 필요할 수도, 그렇지 않을 수도 있다. 디테일한 설정을 위해서 리소스마다 많은 옵션들을 찾아봐야 하며 정상적으로 세팅이 되는지 테스트해야 하고... 이런 시간들이 만만치 않다. 여러 설정들을 생성하고 수정하고를 테스트해 보았는데 실제 운영중인 리소스들을 과연 CDK 를 이용하여 한번에 컨트롤할 수 있을지는 확신하지 못했다. 그리고 반복적인 작업도 마찬가지로 코드레벨에서 naming 과 ip 대역 등의 무수한 수정들이 동반된다고 할 때 CDK 가 과연 나에게 필요한 것인지에 대해서는 확신할 수 없었다. 반복적인 작업에 대해 시간을 단축하려고 맛을 보긴 했지만, 내가 앞으로 지금까지의 시스템을 복제하는데 3일은 안걸릴 것이라는 확신과 함께 빨리 손을 떼었다. 언젠가 시간이 좀 날때 다시 봐야할 듯...


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,