본문 바로가기
관리자

Project/Poppin

코틀린 logback 환경 분리, validated 검증, exception 처리, CD 적용

728x90
반응형

 

1. 코틀린 logback 환경 분리

 

라이브러리 import

implementation("org.springframework.boot:spring-boot-starter-logging:3.1.5")

 

  • resources/ 하위에 logback-spring.xml 파일을 만든다. 반드시 이 이름이여야한다.
  • slack에 메시지를 보내는 설정을 포함했다.
  • <springProfile>로 환경을 구분할 수 있다.
  • timestamp, property, appender, root 등의 logback 문법을 따른다.
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <springProfile name="local">
        <timestamp key="BY_DATE" datePattern="yyyy-MM-dd"/>
        <property name="LOG_PATTERN"
                  value="[%d{yyyy-MM-dd HH:mm:ss}:%-4relative] %green([%thread]) %highlight(%-5level) %boldWhite([%C.%M:%yellow(%L)]) - %msg%n"/>
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <Pattern>%d %-5level %logger{35} - %msg%n</Pattern>
            </encoder>
        </appender>
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>
    <springProfile name="prod">
        <springProperty name="..." source="..."/>
            <appender name="SLACK" class="com.github.maricn.logback.SlackAppender">
                <webhookUri>${SLACK_WEBHOOK_URI}</webhookUri>
                <layout class="ch.qos.logback.classic.PatternLayout">
                    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n</pattern>
                </layout>
                <username>...-${HOSTNAME}</username>
                <iconEmoji>...</iconEmoji>
                <colorCoding>true</colorCoding>
            </appender>

            <!-- Console appender 설정 -->
            <appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
                <encoder>
                    <Pattern>%d %-5level %logger{35} - %msg%n</Pattern>
                </encoder>
            </appender>

            <appender name="ASYNC_SLACK" class="ch.qos.logback.classic.AsyncAppender">
                <appender-ref ref="SLACK"/>
                <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
                    <level>ERROR</level>
                </filter>
            </appender>

            <root level="INFO">
                <appender-ref ref="Console" />
                <appender-ref ref="ASYNC_SLACK"/>
            </root>
    </springProfile>

</configuration>

 

 

 

2. validated 검증

 

자바처럼 코틀린으로 @Valid 어노테이션을 붙여서 검증을 하고 exception을 전달할랬는데 잘 안됐다. 아래처럼 쓰면 된다.

 

Controller 상단에 @Validated 붙이기

@RequestBody 뒤에 @Valid 추가하고, 사용하지는 않더라도 errors 파라미터를 선언해줘야한다.

@RequestMapping("/api/v1/users")
@Validated
class UserController (
    ...
){

    @PostMapping("/...")
    fun createUser(@RequestBody @Valid ...Request: ...Request, errors: Errors): ApiResponse<UserResponse> {
        return ApiResponse(UserResponse.fromUserDto(userService.signup(userSignupRequest)))
    }

 

Request 객체는 아래처럼 처리했다. @field:Pattern을 사용하여 필드마다 제한 조건을 걸었다.

class ...Request(
    @field:Pattern(regexp = "^[ㄱ-ㅎ가-힣A-Za-z0-9-_]{2,20}$", message = "이름은 특수문자를 제외한 2~20자리여야 합니다.")
    @Schema(example = "test", minLength = 2, maxLength = 20)
    var name: String = "",
    var deviceId: String? = null,
    @field:Pattern(regexp = "^[ㄱ-ㅎ가-힣A-Za-z0-9-_]{2,20}$", message = "닉네임은 특수문자를 제외한 2~20자리여야 합니다.")
    @Schema(example = "test", minLength = 2, maxLength = 20)
    var nickname: String? = null,
    @field:Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
    @Schema(example = "test@gmail.com",  minLength = 5, maxLength = 50)
    var email: String = "",
    @field:Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}$", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
    @Schema(example = "Password123!@#", minLength = 8, maxLength = 16)
    var password: String,

 

 

 

3. exception 처리

 

controller에서 발생하는 에러는 @ControllerAdvice로 처리하면 된다. Controller에 문제가 있을 때, AccessDeniedException 등이 발생하거나, throw로 직접 처리하는 경우 ControllerAdvice의 해당하는 handle... 메서드에 의해 처리된다.

ErrorMessage, ResultType은 직접 만든 객체이다.

@ControllerAdvice
class ControllerAdvice {

    @ResponseStatus(HttpStatus.OK)
    @ExceptionHandler(Throwable::class)
    @ResponseBody
    fun handleThrowableException(e: Throwable): ApiResponse<String> {
        when (e.cause) {
            is AccessDeniedException -> return handleAccessDeniedException(e.cause as AccessDeniedException)
            is ClientAbortException -> logger.info(e) { "ClientAbortException: ${e.message}" }
            else -> logger.error(e) { "DefaultExceptionHandler: ${e.message}" }
        }

        val errorMessage = ErrorMessage("Internal Server Error")
        errorMessage.setErrorCode("500")
        return ApiResponse.error(errorMessage)
    }

    @ResponseStatus(HttpStatus.FORBIDDEN)
    @ExceptionHandler(AccessDeniedException::class)
    @ResponseBody
    fun handleAccessDeniedException(e: AccessDeniedException): ApiResponse<String> {
        // 권한이 없을 경우
        val errorMessage = ErrorMessage("Access Denied")
        errorMessage.setErrorCode("403")
        logger.info(e) { "handleAccessDeniedException" }

        return ApiResponse.error(errorMessage)
    }

 

service단의 에러는 RuntimeException을 상속한 CustomException을 선언하여 처리한다.

class CustomException(val code: CustomExceptionCode): RuntimeException() {

}
enum class CustomExceptionCode(val status: HttpStatus, val message: String) {
    // Common
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
    POST_NOT_FOUND(HttpStatus.NOT_FOUND, "찾을 수 없습니다."),
    ALREADY_EXIST(HttpStatus.CONFLICT, "이미 존재하는 정보입니다"),

 

예시. service 코드에서 CustomeException을 알맞는 CustomExceptionCode를 설정하여 throw한다.

fun login(email: String, authProvider: AuthProvider?, password: String): UserDto {
    val user = userRepository.findByEmailAndAuthProvider(email, actualAuthProvider)
        ?: throw CustomException(CustomExceptionCode.POST_NOT_FOUND)

 

CustomException도 ControllerAdvice의 filter를 거치기 때문에 아래처럼 ControllerAdvice에서 CustomException을 처리하는 handler를 추가해줬다.

@ExceptionHandler(CustomException::class)
@ResponseBody
fun handleCustomException(e: CustomException): ApiResponse<String> {
    // Service Layer Runtime Custom Exception
    var errorMessage = ErrorMessage(e.code.message)
    errorMessage.setErrorCode(e.code.status.toString())
    logger.info(e) { e.code.status.toString() }

    return ApiResponse.error(errorMessage, ResultType.EXECUTION_FAIL)
}

 

 

 

4. CD 적용

지난 CI 적용글(AWS Credential(AcceesKey, SecretKey), github Actions - ECR CI 설정하기) 이후에 CD도 적용했다. ECS에 적용하기 위해서 아래 세 가지 step을 추가했다.

env:
  AWS_REGION: ${{ secrets.AWS_REGION }}
  ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}
  ECS_CONTAINER_NAME: ${{ secrets.ECS_CONTAINER_NAME }}
  ECS_SERVICE_NAME: ${{ secrets.ECS_SERVICE_NAME }}
  ECS_CLUSTER_NAME: ${{ secrets.ECS_CLUSTER_NAME }}
  ECS_TASK_DEFINITION_NAME: ${{ secrets.ECS_TASK_DEFINITION_NAME }}
  
  jobs:
  build:
    name: CI/CD
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
  ... 중략 ...

    - name: Retrieve most recent ECS task definition JSON file
      id: retrieve-task-def
      run: |
        aws ecs describe-task-definition --task-definition ${{ env.ECS_TASK_DEFINITION_NAME }} --query taskDefinition > task-definition.json

    - name: Fill in the new image ID in the Amazon ECS task definition
      id: task-def
      uses: aws-actions/amazon-ecs-render-task-definition@v1
      with:
        task-definition: task-definition.json
        container-name: ${{ env.ECS_CONTAINER_NAME }}
        image: ${{ steps.build-image.outputs.image }}

    - name: Deploy Amazon ECS task definition
      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
      with:
        task-definition: ${{ steps.task-def.outputs.task-definition }}
        service: ${{ env.ECS_SERVICE_NAME }}
        cluster: ${{ env.ECS_CLUSTER_NAME }}
        wait-for-service-stability: true
        force-new-deployment: true
  • Retrieve most recent... step부분은 ECS에서 JSON으로된 ECS Task Definition 정보를 가져와서 JSON file로 만들고 프로젝트에 임시로 넣는 코드이다. 이렇게 해야 github repository에 task-definition.json 파일이 올라가서 정보가 유출되는 일이 발생하지 않는다. env.ECS_TASK_DEFINITION_NAME 값은 {태스트 정의 이름}으로만 쓰면 최신 개정 값을, 아니면 {태스트 정의 이름}:{개정 숫자} 로 써도 된다.
  • run | 아래에 있는 aws ecs describe-task-definition ... 부분을 terminal에 써봐도 된다. aws configure가 설정되어있고, 해당 프로젝트의 access_key, secret_key가 default로 설정되어있다면 정상작동해야한다.
$ ~/.aws
$ open credentials

 

 

default 값에 프로젝트의 key 값들이 설정되어있어야한다.

 

 

cluster, service, container name은 각각 ECS의 클러스터, 서비스, 패밀리 이름이다.

 

Deploy... step에서 wait-for-service-stability, force-new-deployment 부분은 각각 github Action이 ECS의 container가 안정화 될때까지 github Action 프로세스를 기다리는 옵션, 강제 배포 적용(위 그림에서 새 배포 강제 적용)을 하는 옵션이다. 

 

더 상세한 옵션들은 github 에서 제공하는 Actions/ aws-actions 문서에서 확인이 가능하다.

https://github.com/aws-actions/amazon-ecs-deploy-task-definition/blob/master/action.yml

728x90
반응형