이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
예외 처리는 애플리케이션을 만드는데 있어서 매우 중요한 부분을 차지합니다.
Spring 프레임워크는 다양한 에러 처리 방법을 제공하는데, 굿프렌즈팀
은 기존에 어떻게 처리했고, 이후에 어떻게 개선했는지 과정을 설명해보겠습니다.
이전 굿프렌즈팀(~23.09.04)에서는 비즈니스 로직에 필요한 예외 처리를 exception 이라는 패키지를 만들어서 아래와 같이 처리했습니다.
@ResponseStatus
은 스프링 프레임워크에서 사용하는 어노테이션으로 처리된 요청에 대한 HTTP 응답 상태 코드와 이에 따른 메시지를 설정하는데 사용됩니다.
package woorifisa.goodfriends.backend.auth.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundTokenException extends RuntimeException {
public NotFoundTokenException(final String message) {
super(message);
}
public NotFoundTokenException() {
this("존재하지 않는 Token 입니다.");
}
}
예를 들어, 토큰에 대한 값을 찾지 못할 경우 위와 같이 @ReponseStatus
을 통해 HTTP 응답 상태 코드인 404 Not Found와 “존재하지 않는 Token 입니다”라는 메시지를 받게 됩니다.
해당 어노테이션을 이용하여 간단하게 상태 코드를 지정할 수 있습니다.
즉, 클라이언트 입장에서 에러 코드가 무엇인지 확인할 수 있다는 장점을 가집니다.
하지만 애플리케이션에 필요한 기능들을 개발하다 보면, 다양한 예외 처리를 만들어야 합니다. 각 예외 처리 클래스마다 위에 @ResponseStatus
을 붙여서 상태 코드를 넣어주는 것은 좋지만, 한 눈에 볼 수 없습니다.
그리고 시간이 지날 수록 본인이 지정한 예외 처리 클래스가 어떤 상태 코드 값인지 제대로 기억하기 쉽지 않습니다.
또한 외부에서 정의한 Exception 클래스에는 @ResponseStatus
를 붙여줄 수 없습니다.
Spring5에는 이러한 @ResponseStatus의 프로그래밍적 대안으로 손쉽게 에러를 반환할 수 있는 ResponseStatusException
이 추가되었습니다.
ResponseStatusException
은 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있습니다.
ResponseStatusException
를 사용하면 다음과 같은 이점을 누릴 수 있습니다.
하지만 그럼에도 불구하고 ResponseStatusException
는 다음과 같은 한계점을 지니고 있습니다.
API 기능을 개발하다보면 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 합니다.
그리고 같은 예외라고 하더라도 어떤 API에서 발생했는가에 따라 다른 예외 응답을 내려주어야 하는 세밀한 작업이 필요하다.
이러한 API 예외 처리를 해결하기 위해 최근에는 스프링에서 제공하는 @ExceptionHandler
어노테이션을 사용하는 방식이 많아졌습니다.
스프링 부트가 기본으로 제공하는 ExceptionResolver
는 다음과 같습니다.
ExceptionHandlerExceptionResolver
-> (우선 순위가 가장 높다)
ResponseStatusExceptionResolver
-> HTTP 응답 코드 변경
DefaultHandlerExceptionResolver
-> 스프링 내부 예외 처리(우선 순위가 가장 낮다)
이때 1번인 ExceptionHandlerExceptionResolver
가 @ExceptionHandler
을 처리합니다.
@ExceptionHandler
는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 어노테이션입니다. @ExceptionHandler
은 다음에 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있습니다.
@ExceptionHandler
는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연합니다. 예를 들어 응답을 다음과 같이 정의해서 내려주면 좋을 것 같습니다.
@RestControllerAdvice
public class ControllerAdvice {
private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class);
@ExceptionHandler({ // 클라이언트 에러: 404
NotFoundUserException.class,
// ...
})
public ResponseEntity<ErrorResponse> handleNotFoundData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
}
@ExceptionHandler의 실행 흐름
애플리케이션 실행 중에 컨트롤러나 서비스 메서드 등에서 특정 예외가 발생할 때, NotFoundUserException
와 일치하는 @ExceptionHandler
어노테이션이 선언된 메서드가 호출됩니다. (여기서는 handleNotFoundData
메소드가 호출됩니다)
예외를 처리하는 메서드(handleNotFoundData
)에서는 해당 예외에 대한 응답을 생성합니다.
위의 코드에서는 ErrorResponse
객체를 생성하고, 해당 응답을 HTTP 상태 코드 404(NOT_FOUND)와 함께 반환합니다.
생성된 응답을 클라이언트에게 전송됩니다. 이 경우, HTTP 상태 코드는 404로 설정되어 클라이언트에게 해당 자원이 발견되지 않았음을 알려줍니다.
이렇게 @ExceptionHandler
어노테이션을 사용하면 특정 예외에 대한 처리 로직을 일괄적으로 정의할 수 있으며, 중복 코드를 피하고 통일된 에러 응답을 생성할 수 있습니다.
위와 같이 NOT_FOUND
와 같은 HTTP 표준 상태와 같이 가독성이 좋은 값을 사용하는 것이 클라이언트의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋습니다.
또한 각 기능별로 예외 처리에 대한 클래스를 @ExceptionHandler
을 통해 한 곳에 관리할 수 있습니다.
@ExceptionHandler
를 사용 시에 주의할 점은 @ExceptionHandler
에 등록된 예외 클래스와 파라미터로 받는 예와 클래스가 동일해야 한다는 것입니다.
만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킵니다.
java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 합니다.
@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다. (글로벌 적용)
Spring은 전역적으로 @ExceptionHandler
를 적용할 수 있는 @ControllerAdvice
와 @RestControllerAdvice
을 각각 Spring3.2, Spring4.3부터 제공하고 있습니다.
두 어노테이션의 차이점은 @RestControllerAdvice
는 @ResponseBody
가 붙어 있어 응답을 Json 형식으로 내려준다는 점입니다.
(@Controller
, @RestController
의 차이와 같습니다)
아래와 같이 @ControllerAdvice와 @RestControllerAdvice의 구현의 일부 입니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
//...
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {
//...
}
굿프렌즈팀에서는 @RestControllerAdvice 어노테이션을 적용한 ControllerAdvice
클래스를 만들어서 메서드 위에 @ExceptionHandler
를 적용했습니다.
대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("com.goofriends.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
(스프링 공식 문서 참고 - https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice)
스프링 공식 문서 예제에서 보는 것 처럼 특정 어노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수도 있습니다.
패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 됩니다.
그리고 특정 클래스를 지정할 수 있습니다.
만약 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용됩니다.
굿프렌즈팀은 400, 401, 403, 404, 405, 500 를 사용하였고, 이에 따라 에러 클래스를 정의해봤습니다.
400 BAD_REQUEST
설명: 서버는 클라이언트의 오류로 인해 요청을 처리할 수 없거나 하지 않을 것입니다.
예시) InvalidUserException.class
: 잘못된 회원 정보입니다.
401 UNAUTHORIZED
설명: 403 Forbidden과 유사하지만 구체적으로 인증이 필요하며 실패하거나 제공되지 않은 경우에 사용됩니다.
예시) EmptyAuthorizationHeaderException.class
: Header에 Authorization이 존재하지 않습니다. / InvalidTokenException.class
: 유효하지 않은 토큰입니다.
인증되지 않은(unauthenticated) 요청이 들어왔을 때 사용합니다.
클라이언트에서 해당 요청을 보내기 위해서는 자신이 누구인지 알려주는 인증 정보가 필요한데, 인증 정보가 누락된 경우에 사용합니다.
403 FORBIDDEN
설명: 인가되지 않은(unauthorization) 요청이 들어왔을 때 사용합니다.
예시) AuthorizationException.class
: 권한이 없습니다. / InactiveUserAccessException.class
: 비활성화 상태인 유저로 해당 페이지에 접근이 불가능합니다.
서버는 요청을 이해했지만 권한이 없어 이를 거부합니다. 요청이 거부된 이유를 공개하려는 서버는 응답에서 해당 이유를 설명할 수 있습니다.
=> 필요한 권한을 갖고 있지 않는 경우에 사용됩니다.
404 NOT_FOUND
설명: 서버에서 요청한 리소스를 찾을 수 없습니다.
예시) NotFoundProductException.class
: 존재하지 않는 물품입니다. / NotFoundOrderException
: 주문서가 존재하지 않습니다.
405 METHOD_NOT_ALLOWED
설명: 요청 라인에서 수신한 메서드는 원본 서버에서 알고 있지만 대상 리소스에서는 지원되지 않습니다.
예시) HttpRequestMethodNotSupportedException.class
: 지원하지 않는 HTTP 메서드 요청입니다.
500 INTERNAL_SERVER_ERROR
설명: 서버 측에서 오류가 발생했음을 나타냅니다. 이는 클라이언트의 요청을 처리하는 동안 서버가 예기치 않은 상황에 직면하여 요청을 완료할 수 없는 경우에 사용됩니다.
예시) Exception.class
: 서버에서 예상치 못한 에러가 발생했습니다. / OAuthException.class
: Oauth 서버와의 통신 과정에서 문제가 발생했습니다.
아래의 ControllerAdvice
는 여러 컨트롤러에 적용하기 때문에 굿프렌즈팀의 global - error 패키지 안에 만들었습니다.
@RestControllerAdvice
public class ControllerAdvice {
private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class);
@ExceptionHandler(MethodArgumentNotValidException.class) // 클라이언트 에러: 400
public ResponseEntity<ErrorResponse> handleMethodArgumentException(BindingResult bindingResult) {
String errorMessage = bindingResult.getFieldErrors()
.get(0)
.getDefaultMessage();
ErrorResponse errorResponse = new ErrorResponse(errorMessage);
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 400
InvalidNicknameException.class,
InvalidUserException.class,
InvalidDescriptionException.class,
ReportException.class
})
public ResponseEntity<ErrorResponse> handleInvalidData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 401
EmptyAuthorizationHeaderException.class,
InvalidTokenException.class,
})
public ResponseEntity<ErrorResponse> handleInvalidAuthorization(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
@ExceptionHandler({ // 클라이언테 에러: 403
NotFoundProfile.class,
NotAccessThisProduct.class,
AuthorizationException.class,
NotAccessProduct.class,
AlreadyExitPhoneProfile.class,
OwnProductException.class,
NotOwnProductException.class
})
public ResponseEntity<ErrorResponse> handleForbidden(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 404
NotFoundAdminException.class,
NotFoundOAuthTokenException.class,
NotFoundTokenException.class,
NotFoundProductException.class,
NotFoundImageFileException.class,
NotFoundUserException.class,
NotFoundOrderException.class
})
public ResponseEntity<ErrorResponse> handleNotFoundData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) // 클라이언트 에러: 405
public ResponseEntity<ErrorResponse> handleNotSupportedMethod() {
ErrorResponse errorResponse = new ErrorResponse("지원하지 않는 HTTP 메서드 요청입니다.");
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);
}
@ExceptionHandler(OAuthException.class) // 서버 에러: 500
public ResponseEntity<ErrorResponse> handleOAuthException(final RuntimeException e) {
log.error(e.getMessage(), e);
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.internalServerError().body(errorResponse);
}
@ExceptionHandler(Exception.class) // 서버 에러: 500
public ResponseEntity<ErrorResponse> handleUnexpectedException(final Exception e,
final HttpServletRequest request) {
ErrorReportRequest errorReportRequest = new ErrorReportRequest(request, e);
log.error(errorReportRequest.getLogMessage(), e);
ErrorResponse errorResponse = new ErrorResponse("서버에서 예상치 못한 에러가 발생했습니다.");
return ResponseEntity.internalServerError().body(errorResponse);
}
}
ControllerAdvice
이점 및 주의할 점ControllerAdvice
는 전역적으로 적용되는데, 지금과 같이 특정 클래스에만 제한적으로 적용하기 위해 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있습니다.
이러한 ControllerAdvice
를 이용함으로써 다음과 같은 이점을 누릴 수 있습니다.
하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
별도의 try-catch문이 없어 코드의 가독성이 높아짐
이러한 이유로 API에 의한 예외 처리를 하는 경우 ControllerAdvice
클래스를 이용하면 됩니다.
하지만 ControllerAdvice
클래스를 사용할 때는 항상 주의해야할 점이 있습니다.
여러 ControllerAdvice
가 있는 경우 @Order
어노테이션 으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice
를 임의의 순서로 처리할 수 있기 때문에 일관된 예외 응답을 처리하기 위해서는 이러한 점을 주의해야 합니다.
한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋습니다.
만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 합니다.
직접 구현한 Exception 클래스들은 한 공간에서 관리합니다.
Spring은 매우 다양한 예외 처리 방법을 제공하고 있어 어떻게 에러를 처리하는 것이 최선의 방법인지 파악하기 어려울 수 있습니다. 여러 스프링의 다양한 예외 처리 방법들 중에 현 프로젝트에 ControllerAdvice를 이용하는 것이 가장 좋은 방식인 것 같아 적용해보았습니다. 이후에 팀원들과 프로젝트를 진행하면서 혹시 이보다 더 좋은 방안이 떠오른다면, 다른 포스팅으로 찾아뵙겠습니다.
지금까지 읽어주셔서 감사합니다. 😌
/**
* 개인정보 수집 유효기간
* @param today 오늘 날짜 YYYY.MM.DD
* @param terms 약관의 유효기간 (약관종류 유효기간) : 공백으로 구분, 유효기간은 개월수
* @param privacies 수집된 개인정보 (날짜 약관종류) : 공백으로 구분
* @return 파기해야할 개인정보의 번호 오름차순
*/
import java.util.*;
class Solution {
public int[] solution(String today, String[] terms, String[] privacies) {
int[] answer = {};
Map<String, String> termsMap = new HashMap<>(); // key: 약관 종류, value: 유효 기간
for(String term: terms) {
String[] termSplit = term.split(" ");
termsMap.put(termSplit[0], termSplit[1]);
}
Integer number = 1; // privacies의 번호
List<Integer> result = new ArrayList<>();
// 현재 총 날짜 수
Integer todayTotalDate = getTotalDate(today, 0);
for(String p : privacies) {
String[] privateSplit = p.split(" ");
// 개인별 날짜
String privateDate = privateSplit[0];
// 개인별 약관 종류
String privateTerm = privateSplit[1];
// 약관 개월수
Integer termsMonth = Integer.valueOf(termsMap.get(privateTerm));
// 기간 경과후 총 날짜 수
Integer privateTotalDate = getTotalDate(privateDate, termsMonth) - 1;
// 기간 경과후 날짜가 현재날짜보다 미만이면 폐기 대상
if(privateTotalDate < todayTotalDate) {
// 현재 당일은 아직 폐기 대상 아님
// 유효기간 경과하여 폐기대상인 번호를 추가
result.add(number);
}
// privacies의 번호 + 1
number++;
}
answer = result.stream().mapToInt(Integer::intValue).toArray();
return answer;
}
/**
* (YYYY.MM.DD)을 총 날짜 수로 환산
* @param strDate YYYY.MM.DD
* @param termsMonth 약관 개월 수
* @return 날짜로 환산한 총 날짜 수
*/
private Integer getTotalDate(String strDate, Integer termsMonth) {
String [] dateSplit = strDate.split("\\.");
Integer year = Integer.valueOf(dateSplit[0]);
Integer month = Integer.valueOf(dateSplit[1]) + termsMonth;
Integer day = Integer.valueOf(dateSplit[2]);
// 모두 일 수로 환산, 한 달은 28일
return (year * 12 * 28) + (month * 28) + day;
}
}
조건) 모든 달은 28일 까지 있다고 가정
today
: 오늘 날짜를 의미하는 문자열 → “YYYY
.MM
.DD
”
terms
: 약관의 유효 기간을 담은 1차원 문자열 배열 → “약관 종류
, 유효 기간
”
privacies
: 수집된 개인정보의 정보를 담은 1차원 문자열 배열 → “날짜
, 약관 종류
”
출력) 파기해야할 개인정보의 번호를 오름차순으로 1차원 정수 배열에 담아 return
약관의 종류별로 유효 기간을 취득하기 위해 약관의 유효 기간을 담는 terms
를 Map으로 변환해준다.
(key: 약관 종류, value: 유효 기간)
Map<String, String> termsMap = new HashMap<>();
오늘 날짜를 총 날짜 수로 변환해준다.
참고) 연월일을 총 날짜수로 변환하는 식: (연도 * 12개월 * 28일) + (월 * 28일) + 일
Integer todayTotalDate = (today
의 YYYY * 12 * 28) + (today
의 MM * 28일) + (today
의 DD)
개인정보 privacies
배열을 하나씩 확인해가며 약관이 유효한지 만료되었는지 확인한다.
날짜
와 약관종류
를 분리하여 취득한다.
0
은 날짜이며, 인덱스 1
은 약관 종류가 된다.계산된 전체 날짜 수를 비교하여 폐기 대상인지 확인한다.
(유효 기간을 고려한 약관 날짜 수 < today의 총 날짜수) 이면, 폐기 대상이다.
if( privateTotalDate < todayTotalDate) 폐기대상
today 당일의 경우에도 아직 유효 기간이 지나지 않아 폐기 대상이 아니므로 today 미만으로 계산한다.
이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
굿프렌즈팀은 프로젝트를 진행하면서 어떠한 패키지를 구성하면 좋을지 고민했습니다. 보통 패키지를 구조를 나누는 방법으로 대표적인 패키지 구조인 계층별
, 기능별
이 있습니다.
이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
저번에 포스팅 했던 젠킨스를 사용하여 CI/CD Pipeline 구축기(프론트엔드편) 에 이어서 이번 글은 프론트엔드/백엔드의 코드가 PR시 병합되었을 때 라벨로 구분하여 젠킨스를 빌드하는 과정을 소개하고자 합니다.
현재 굿프렌즈팀의 젠킨에서 빌드 트리거로 Github hook trigger for GITScm polling
을 사용하고 있습니다.
파이프라인 스크립트 부분에서 아래와 같이 빌드 트리거가 동작하기 위한 스크립트 작성 부분입니다.
현재 굿프렌즈팀의 레포지토리를 보시면, 프론트엔드와 백엔드 코드가 같이 관리되고 있습니다. 두 개의 코드 중 하나라도 병합(Merge)되었을 때 프론트엔드, 백엔드 같이 빌드 및 배포되고 있습니다.
PR시 Merge되었을 때 각 트랙별로 코드가 빌드 및 배포될 수 있도록 빌드 트리거를 조금 더 세분화하여 Github의 라벨을 기반으로 트리거를 개선하려고 합니다.
현재 굿프렌즈팀은 라벨을 이용하여 트랙별 작업 구분을 하고 있습니다.
따라서 이를 이용하여 라벨별로 빌드 및 배포를 진행할 수 있도록 구성하였습니다.
Plugins - Available plugins에서 Generic Webhook Trigger
설치후 재시작합니다.
그런 다음 Plugins - Installed plugins - 설치된 Generic Webhook Trigger
가 있는지 확인합니다.
Generic Webhook Trigger는 젠킨스에서 제공하는 플러그인으로 HTTP 요청을 수신하여 JSON, XML 형태의 데이터를 추출하여 트리거를 지정할 수 있는 플러그인입니다. Generic Webhook Trigger를 사용하여 빌드 유발 상황을 구분하려고 합니다.
플러그인을 설치했다면 젠킨스 파이프라인의 Build Triggers 설정에서 아래와 같이 Generic Webhook Trigger를 확인할 수 있습니다.
해당 부분을 클릭하면 아래와 같은 설명을 확인하실 수 있습니다.
http://JENKINS_URL/generic-webhook-trigger/invoke
로 오는 요청에 대해 트리거가 수행된다는 말이 적혀 있습니다.
위 설명에 맞춰 GitHub에서 WebHook 설정을 변경해주어야 합니다.
위에서 설명한 탭에 대해서는 아직 저장하지 않고 깃허브로 넘어와서 진행합니다.
젠킨스 설정은 아래에서 설명하는 Github WebHook 설정 이후에 이어서 진행합니다.
기존에 설정된 한개의 Webhook을 확인하실 수 있습니다.
프로젝트 레포지토리 -> Setting -> Webhook 탭에 들어와서 웹훅을 새로 만듭니다. (Add Webhook 버튼 클릭)
payload URL: http://{jenkins 퍼블릭 ip 주소}:80/generic-webhook-trigger/invoke
Payload URL을 위에서 확인한 Generic Webhook Trigger의 설정에 맞게 작성합니다.
그리고 트리거가 동작하는 이벤트에 대해서 Send me everything 옵션을 체크합니다.
Webhooks / Manage webhook
Add webhook을 한 뒤 다시 등록한 웹 훅을 클릭하여 Recent Deliveries 탭을 확인하면,
Webhooks / Manage webhook (detail)
내용을 보면 요청을 보낼 때 Payload에 다양한 정보들이 담겨있는 것을 확인할 수 있습니다.
젠킨스 파이프라인 설정 구성에 들어와서 작업을 이어나갑니다.
기존에 설정해둔 GitHub hook trigger for GITScm polling 설정을 해제한 다음, Generic WebHook Trigger 를 활성화합니다.
Post content parameters의 추가 버튼을 눌러 파라미터를 추가합니다.
MERGED
$.pull_request.merged
를 등록하여 Merge 되었는지 여부를 가져옵니다.
BRANCH
$.pull_request.base.ref
를 등록하여 base 브랜치를 가져옵니다.
원하는 값은 develop 브랜치가 될 것입니다.
LABELS
$.pull_request.labels..name
를 등록하여 라벨들의 이름을 가져옵니다.
원하는 값은 등록된 라벨들 중에 백엔드를 포함하는 값이 존재하는 상황이 될 것입니다.
아래로 내리다 보면 Optional filter
탭을 볼 수 있습니다.
Expression에 다음과 같은 정규표현식을 작성했습니다.
(?=.*true)(?=.*develop)(?=.*백엔드).*
또한 Text 부분에는 위에서 파라미터로 선언한 값들을 사용했습니다.
위 표현식으로 다음과 같은 효과를 기대할 수 있다.
merge가 true이고, 브랜치가 develop이고, 라벨에 백엔드가 존재하는 작업에 대해 트리거를 수행하는 것을 기대할 수 있습니다.
Token
: goodfriends_build_webhook_token
Token 탭에 토큰 이름을 작성합니다.
그리고 다시 Github - Webhooks로 이동합니다.
이때 GitHub WebHook 설정에 들어가서 Payload URL
에 token 파라미터를 포함하도록 수정합니다.
Payload URL
: http://{jenkins 퍼블릭 ip 주소}:80/generic-webhook-trigger/invoke?token=goodfriends_build_webhook_token
바로 아래 탭에서 TokenCredential에 토큰에 대한 Secret Key를 추가해주어야 합니다.
외부에서 빌드를 유발하기 위해서는 token을 확인하는 방식이 존재하는데 이때 Jenkins의 Credential에 있는 정보를 기반으로 수행합니다.
token을 사용하지 않는 경우 계정정보를 입력할 수도 있지만 해당 과정에서는 token을 등록하는 방식을 사용합니다.
Domain
: Global credentialsKind
: Secret textSecret
: goodfriends_build_webhook_tokenID
: GoodFriends_Web_Hook_TokenDescription
: GoodFriends Web Hook Token입니다.위와 같은 정보로 Secret text를 작성하고 등록하고 적용합니다
GitHub의 WebHook 탭의 Recent Deliveries 탭으로 가서 확인해 보면 Response Body 값에 응답이 담겨있는 것을 확인할 수 있습니다.
젠킨스 - General 부분의 Generic Webhook Trigger 설정 - Token 설정하는 부분에서 많은 시간을 할당했습니다.
{
"jobs":null,"message":"Did not find any jobs with GenericTrigger configured! If you are using a token, you need to pass it like ...trigger/invoke?token=TOKENHERE. If you are not using a token, you need to authenticate like http://user:passsword@example.org/generic-webhook... "
}
404 에러와 함께 위 메시지가 반환되었는데, 젠킨스에서 Token에 대해 이해를 하지 못해서 생긴 문제였습니다.
“외부에서 빌드를 유발하기 위해서는 token을 확인하는데 이때, 젠킨스의 Credential에 있는 정보를 기반으로 수행한다”
즉, Jenkins의 Credential에서 token에 대한 Credential를 추가해 주고, 이를 적용해야 한다는 의미였습니다.
젠킨스의 Generic Webhook Trigger 플러그인을 이용하여 Merge 여부, target branch, label 값을 비교하고 빌드를 유발하는 설정을 했습니다. 이후에는 백엔드를 기준으로 했기 때문에, 백엔드의 코드가 병합했을 경우에만 젠킨스에서 빌드 및 배포 처리가 됩니다.
경우에 따라 세부적인 검증을 추가할 수 있습니다.
이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
직전 포스팅에서 굿프렌즈팀 백엔드의 CI/CD를 구축한 방법을 소개해드렸습니다. 이번 포스팅에서는 프론트엔드의 CI/CD를 구축하는 방법을 소개하고자 합니다.
프론트엔드의 지속적 배포 환경은 백엔드와 크게 다른점은 없습니다. PR이 생성되고, 병합되어 Webhook을 통해 젠킨스 서버에 전달됩니다. 젠킨스 서버는 Webhook을 이용하여 뷰(Vue) 프로젝트를 빌드하고, index.html과 관련 js 파일을 생성합니다. 그리고 생성된 정적 파일들을 프론트엔드 EC2 인스턴스에 있는 NGINX 디렉토리를 통해 전송됩니다.
굿프렌즈팀 프론트엔드 팀은 뷰(Vue)를 사용합니다.
젠킨스에는 백엔드 언어인 Java와 다르게 NodeJs의 빌드를 기본적으로 제공하고 있지 않습니다. 하지만 이를 지원하는 NodeJs 플러그인이 존재하여 사용자는 해당 플러그인을 설치하는 과정만으로 NodeJs 프로젝트를 빌드할 수 있는 환경을 구축할 수 있습니다.
이를 위해 굿프레즈팀은 젠킨스에 NodeJs 플러그인을 설치했습니다.
설치하는 과정은 젠킨스 홈페이지에서 Dashboard - Jenkins 관리 - Plugins - Available plugins → NodeJs 선택 후 설치해주면 됩니다. 그런 다음 젠킨스를 재시작해줍니다.
다시 Dashboard - Jenkins 관리 - Tools 에서 아래 그림과 같이 설정한 뒤 저장합니다.
굿프렌즈팀은 개발 환경에서 node.js 18.12.0 버전을 사용하므로 18.12.0 버전을 선택하고 이름을 NodeJS 18.12.0
으로 지정했습니다.
Jenkins 관리 → Credentials → Stores scoped to Jenkins 탭에서 System 하이퍼링크 클릭
Global credentials 클릭 → 우측 상단 Add Credentials 선택(Add domain 아님)
Credentials
Systyem
Global credentials
Kind
: SSH Username with private keyScope
: GlobalUsername
: ubuntu (linux 계정이름)Password
: 프론트엔드 EC2 서버에 접속할 때 사용하던 pem 키의 값(하단 pem 키 조회참조)ID
: from-jenkins-to-aws-nginx-ec2-access-keyDescription
: access-key-for-nginx-ec2-serverTreat username as secret
: 체크Private Key 탭 하단
-> Enter directly 선택 -> Key 부분 하단 Add 버튼 누른 후 프론트엔드 EC2 서버에 연결할 때 사용되는 pem 키의 내용 입력 후 저장프론트엔드 운영 서버에서 실행시킬 deploy.sh를 작성해줘야 합니다.
프론트엔드 서버에 ssh 연결한 뒤 deploy.sh를 아래와 같이 작성합니다.
#!/bin/bash
tar -xvf dist.tar
rm -rf dist.tar
sudo rm -rf /var/www/html/dist # remove origin's dist file
sudo mv dist /var/www/html # move new dist file to /var/www/html
sudo service nginx restart
젠킨스 서버에서 프론트엔드 운영 서버로 접속하기 위해 프론트엔드 보안 그룹내에 있는 인바운드 규칙을 설정해줘야 합니다.
유형
: SSH, 소스에는 젠킨스 Private IP 주소를 넣어서 프론트엔드 서버 접속을 가능하게 합니다.
pipeline {
agent any
tools {
gradle 'gradle'
}
triggers {
githubPush()
}
stages {
stage('Copy Repository') {
steps {
git branch: 'develop', credentialsId: 'jenkins-credential-username-password', url: 'https://github.com/woorifisa-projects/GoodFriends'
}
}
stage('Frontend secret file download') {
steps {
dir("./frontend") {
sh 'rm -rf .env'
}
script {
withCredentials([
file(credentialsId: 'secret-env', variable: 'ENV')
]) {
sh 'cp $ENV frontend/.env'
}
}
}
}
stage('Frontend Build'){
steps{
sh "echo '########### FE MODULE INSTALL AND BUILD START ###########'"
dir("./frontend"){
nodejs(nodeJSInstallationName: 'nodejs-18.12.0') {
sh 'npm install && npm run build'
}
}
sh "echo '########### FE MODULE INSTALL AND BUILD SUCCESS ###########'"
}
}
stage('Frontend Compression') {
steps {
dir("./frontend") {
sh '''
rm -rf node_modules
tar -cvf dist.tar dist
'''
}
}
}
stage('Frontend Deploy') {
steps {
sshagent(credentials: ['from-jenkins-to-aws-nginx-ec2-access-key']) {
sh '''
ssh -o StrictHostKeyChecking=yes ubuntu@172.31.34.44 uptime
scp /var/jenkins_home/workspace/Goodfriends-pipeline/frontend/dist.tar ubuntu@172.31.34.44:/home/ubuntu
ssh -t ubuntu@172.31.34.44 chmod +x ./deploy.sh
ssh -t ubuntu@172.31.34.44 ./deploy.sh
'''
}
}
post {
success {
slackSend (
channel: '#jenkins',
color: '#00FF00',
message: "프론트엔드 배포 성공! 🚀"
)
}
failure {
slackSend (
channel: '#jenkins',
color: '#FF0000',
message: "프론트엔드 배포 실패 🥲"
)
}
}
}
stage('Backend secret file download') {
steps {
script {
withCredentials([
file(credentialsId: 'secret-application-yml', variable: 'YML'),
file(credentialsId: 'secret-application-db-yml', variable: 'DbYML'),
file(credentialsId: 'secret-application-oauth-yml', variable: 'OauthYML'),
file(credentialsId: 'secret-application-sms-yml', variable: 'SmsYML')
]) {
sh 'cp $YML backend/src/main/resources/application.yml'
sh 'cp $DbYML backend/src/main/resources/application-db.yml'
sh 'cp $OauthYML backend/src/main/resources/application-oauth.yml'
sh 'cp $SmsYML backend/src/main/resources/application-sms.yml'
}
}
}
}
stage('Backend Build') {
steps {
dir('backend') {
sh 'chmod +x ./gradlew'
sh './gradlew clean build'
}
}
}
stage('Backend Deploy') {
steps {
sshagent(credentials: ['from-jenkins-to-aws-backend-ec2-access-key']) {
sh '''
ssh -o StrictHostKeyChecking=yes ubuntu@172.31.39.91 uptime
scp /var/jenkins_home/workspace/Goodfriends-pipeline/backend/build/libs/goodfriends-0.0.1-SNAPSHOT.jar ubuntu@172.31.39.91:/home/ubuntu
ssh -t ubuntu@172.31.34.44 chmod +x ./deploy.sh
ssh -t ubuntu@172.31.39.91 ./deploy.sh
'''
}
}
post {
success {
slackSend (
channel: '#jenkins',
color: '#00FF00',
message: "백엔드 배포 성공! 🚀"
)
}
failure {
slackSend (
channel: '#jenkins',
color: '#FF0000',
message: "백엔드 배포 실패 🥲"
)
}
}
}
}
}
기존 백엔드 파이프라인을 포함하여 프론트엔드 파이프라인 스크립트를 작성했습니다.
이렇게 파이프라인을 작성한 뒤에 배포를 실행하면 아래와 같이 정상적으로 빌드 및 배포가 완료된 것을 확인할 수 있습니다.
2023.08.26(이전)
2023.10.14(최근)
위의 pipeline에 맞게 수정된 jenkins 부분입니다! (9월 말에 우리FISA로부터 지원이 끊겨서 갑작스럽게 모든 서버 구축(프론트, 백엔드, 젠킨스)을 처음부터 다시 하게 되었습니다ㅠㅠ 하지만, 다시 한번 구축하면서 복습도 할 수 있었고, 동작 원리가 머릿 속에 정리되어서 빠르게 구축하게 되었습니다! 😆)
현재 프론트엔드와 백엔드 모두 develop 브랜치에서 작업을 하다보니 둘 중 어느 한쪽의 코드만 병합(Merge)되어도 프론트엔드, 백엔드 두개의 빌드 프로세스가 실행되는 이슈가 존재합니다.
이후에는 빌드 트리거를 조금 더 세분화하여 Github의 라벨을 기반으로 트리거를 개선하려고 합니다.
긴글 읽어주셔서 감사합니다. 😌