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
'Project > Poppin' 카테고리의 다른 글
AWS ECR -> ECS 배포(VPC, 서브넷, NAT, ASG, 로드밸런서 등 전부!) 기초 (0) | 2024.01.14 |
---|---|
mysql dump(docker instance) (1) | 2024.01.01 |
스프링부트: 시큐리티- 회원가입, 로그인 기능 추가하기, swagger 로그인 하기, @ControllerAdvice (0) | 2023.12.25 |
S3 이미지 업로드 구현, Profile 설정 및 불러오기, Swagger 이미지 업로드(multipart) (0) | 2023.12.25 |
Thymeleaf로 input 확인 및 수정 Admin 페이지 만들기(jquery, script) (0) | 2023.12.17 |