티스토리 뷰

반응형

예외 처리는 robust한 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 어떠한 방법들이 있고 가장 좋은 방법(Best Practice)은 무엇인지 살펴보도록 하자.

 

 

 

1. Spring의 기본적인 예외 처리 방법


Spring이 제공하는 다양한 예외 처리 방법들을 살펴보기 전에, 먼저 우리가 개발한 Controller에서 발생한 예외를 Spring은 기본적으로 어떻게 처리하고 있는지 살펴보도록 하자. 아래의 내용들은 SpringBoot를 기반으로 설명된다.

 

 

 

[ Spring의 기본적인 예외 처리 방식(SpringBoot) ]

예를 들어 우리가 만든 다음과 같은 컨트롤러가 있다고 하자.

@RestController
@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;
  
  @GetMapping("/product/{id}")
  public Response getProduct(@PathVariable String id){
    // this method throws a "NoSuchElementFoundException" exception
    return productService.getProduct(id);
  }
}

 

 

getProduct에서 NoSuchElementFoundException 예외가 발생했다면 우리는 접속한 환경에 따라서 다른 에러 처리를 받게 될 것이다. 만약 우리가 웹페이지로 접속했다면 다음과 같은 whiltelabel 에러 페이지를 반환받는다.

 

 

Spring은 만들어질 때(1.0)부터 에러 처리를 위한 BasicErrorController를 구현해두었다. 만약 웹 브라우저에서 예외가 발생하면 errorHtml()을 거쳐 ViewResolver를 통해 에러 페이지가 반환되고, 브라우저가 아닌 곳(Postman, Curl, 서버 등)에서 요청을 한다면 error()를 거쳐 에러 메세지을 받게 된다. 에러 경로는 기본적으로 /error로 정의되어 있으며 properties에서 server.error.path로 변경할 수 있다.

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

    private final ErrorProperties errorProperties;
    ...

    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
        ...
    }

    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        ...
        return new ResponseEntity<>(body, status);
    }

    @ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
    public ResponseEntity<String> mediaTypeNotAcceptable(HttpServletRequest request) {
        HttpStatus status = getStatus(request);
        return ResponseEntity.status(status).build();
    }
}

 

 

errorHtml()과 error()는 모두 getErrorAttributeOptions를 호출해 반환할 에러 속성을 얻는데, 기본적으로 DefaultErrorAttributes로부터 반환할 정보를 가져온다. DefaultErrorAttributes는 다음과 같은 전체 항목들에서 프로퍼티 설정에 따라 불필요한 속성들을 제거한다.

  • timestamp: 에러가 발생한 시간
  • status: 에러의 Http 상태
  • error: 에러 코드
  • path: 에러가 발생한 uri
  • exception: 최상위 예외 클래스의 이름(설정 필요)
  • message: 에러에 대한 내용(설정 필요)
  • errors: BindingExecption에 의해 생긴 에러 목록(설정 필요)
  • trace: 에러 스택 트레이스(설정 필요)
{
    "timestamp": "2021-12-31T03:35:44.675+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/product/5000"
}

 

 

위는 기본 설정으로 받는 에러 응답인데, 나름 잘 갖추어져 있지만 클라이언트 입장에서 유용한 정보를 주지 못한다. 클라이언트는 “Item with id 5000 not found”라는 메세지와 함께 해당 리소스가 없다는 404 status로 에러 응답을 받으면 훨씬 유용할 것이다. 이러한 문제를 해결하기 위해 properties를 수정할 수 있다. 참고로 SpringBoot 2.3 이전에는 message를 기본적으로 제공하고 있었지만, SpringBoot 2.3부터는 클라이언트에게 너무 많은 정보가 노출되는 것을 방지하기 위해 message가 기본적으로 않게 되었다.

server.error.include-message: always
server.error.include-binding-errors: always
server.error.include-stacktrace: always
server.error.include-exception: false

 

 

위와 같이 설정을 변경함으로써 에러 응답을 조정할 수 있다. 물론 운영 환경에서 구현이 노출되는 trace는 제공하지 않는 것이 좋다.

{
    "timestamp": "2021-12-31T03:35:44.675+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "java.util.NoSuchElementException: No value present ...",
    "message": "No value present",
    "path": "/product/5000"
}

 

 

설정을 변경했음에도 불구하고 status는 여전히 500이며, 유의미한 에러 응답을 전달하지 못한다. 그러므로 우리는 별도의 에러 처리 전략을 사용해서 응답을 변경해 줄 필요가 있다.

 

 

 

 

2. Spring이 제공하는 다양한 예외처리 방법


Java에서 예외 처리를 하기 위해서는 try-catch를 사용해야 한다. 하지만 try-catch를 모든 코드에 붙이는 것은 가독성을 떨어뜨린다. Spring은 이러한 문제를 해결하기 위해 에러 처리라는 공통 관심사(cross-cutting concerns)를 메인 로직으로부터 분리하는 다양한 예외 처리 방식을 고안하였고, 이를 위해 예외 처리 전략을 추상화한 HandlerExceptionResolver 인터페이스를 만들었다. (전략 패턴이 사용된 것이다.)

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, 
            HttpServletResponse response, Object handler, Exception ex);
}

 

 

위의 Object 타입 handler는 예외가 발생한 컨트롤러 객체인데, 컨트롤러에서 예외가 던져지면 디스패처 서블릿까지 전달된다. 디스패처 서블릿은 상황에 맞는 적합한 예외 처리 전략을 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 리스트로 관리한다. 그리고 적용 가능한 예외 처리기(구현체)를 찾아 예외 처리를 하는데, 기본적으로 아래의 4가지 구현체들이 빈으로 등록되어 있다.

  • DefaultErrorAttributes: 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • DefaultHandlerExceptionResolver: 스프링의 예외들을 처리한다.
  • ResponseStatusExceptionResolver: @ResponseStatus 또는 ResponseStatusException에 의한 예외를 처리한다.
  • ExceptionHandlerExceptionResolver: Controller나 ControllerAdvice에 있는 ExceptionHandler에 의한 예외를 처리한다.

 

 

DefaultErrorAttributes는 직접 예외를 처리하지 않고 속성만 관리하므로 성격이 다르다. 그래서 내부적으로는 DefaultErrorAttributes를 제외하고 직접 예외를 처리하는 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리한다. 즉, 컴포지트 패턴을 적용해 실제 예외 처리기들을 따로 관리하는 것이다. (컴포지트 패턴이 적용된 코드는 이 포스팅을 참고해주세요)

 

 

그러면 이제 각각의 예외 처리 방식에 대해 자세히 살펴보도록 하자.

  1. ResponseStatus
  2. ExceptionHandler
  3. ControllerAdvice, RestControllerAdvice
  4. ResponseStatusException

 

 

[ @ResponseStatus ]

어노테이션 이름에서 예측가능하듯이 @ResponseStatus는 에러 HTTP 상태를 변경하도록 도와주는 어노테이션이다. @ResponseStatus는 다음과 같은 경우들에 적용할 수 있다.

  • Exception 클래스 자체
  • 메소드에 @ExceptionHandler와 함께
  • 클래스에 @RestControllerAdvice와 함께

 

예를 들어 우리가 만든 예외 클래스에 다음과 같이 @ResponseStatus로 응답 상태를 지정해줄 수 있다.

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException {
  ...
}

 

 

그러면 ResponseStatusExceptionResolver가 지정해준 상태로 에러 응답이 내려가도록 처리한다.

{
    "timestamp": "2021-12-31T03:35:44.675+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/product/5000"
}

 

 

하지만 이러한 @ResponseStatus는 다음과 같은 한계점들을 가지고 있다.

  • 에러 응답의 내용(Payload)를 수정할 수 없음
  • 예외 상황마다 예외 클래스를 추가해야 됨
  • 예와 클래스와 강하게 결합되어 모든 예외에 대해 동일한 상태와 에러 메세지를 반환하게 됨

 

물론 위에서 server.error 프로퍼티 설정을 통해 일부 문제를 해결할 수 있다. 하지만 개발자가 원하는대로 에러를 처리하는 것은 어려운데, 이러한 문제를 해결하기 위해서는 다른 방법을 사용해야 한다.

 

 

 

[ @ExceptionHandler ]

@ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다. @ExceptionHandler는 다음의 경우들에 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.

  • 컨트롤러의 메소드
  • @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드

 

 

예를 들어 다음과 같이 컨트롤러의 메소드에 @ExceptionHandler를 추가함으로써 에러를 처리할 수 있다. @ExceptionHandler에 의해 발생한 예외는 ExceptionHandlerExceptionResolver에 의해 처리가 된다.

@RestController
@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;
  
  @GetMapping("/product/{id}")
  public Response getProduct(@PathVariable String id){
    return productService.getProduct(id);
  }

  @ExceptionHandler(NoSuchElementFoundException.class)
  public ResponseEntity<String> handleNoSuchElementFoundException(NoSuchElementFoundException exception) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
  }
}

 

 

@ExceptionHandler는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 또한 @ResponseStatus와도 결합가능한데,  만약 ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity가 우선순위를 갖는다.

ExceptionHandler는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내려준다면 좋을 것이다.

  • code: 어떠한 종류의 에러가 발생하는지에 대한 에러 코드
  • message: 왜 에러가 발생했는지에 대한 설명
  • erros: 어느 값이 잘못되어 @Valid에 의한 검증이 실패한 것인지를 위한 에러 목록

 

여기서 code로 E001, E002 등과 같이 내부적으로 정의한 값을 사용하기도 하는데, 그것보다 BAD_REQUEST와 같은 Http 표준 상태나 INVALID_PARAMETER 등과 같이 가독성 좋은 값을 사용하는 것이 클라이언트의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋다.

@RestController
@RequiredArgsConstructor
public class ProductController {

    ...

    @ExceptionHandler(NoSuchElementFoundException.class)
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(NoSuchElementFoundException exception) {
        ...
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        ...
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception exception) {
        ...
    }
}

 

 

Spring은 예외가 발생하면 먼저 가장 구체적인 예외 핸들러를 찾고, 찾지 못하면 부모 예외의 핸들러를 찾는다. 예를 들어 위의 경우에 NullPointerException이 발생했다고 하자. 위에서는 NullPointerException에 대한 처리기가 없으므로 RuntimeException에 대한 처리기를 찾고, 또 없어서 최종적으로 Exception에 대한 예외처리기가 찾아질 것이다.

@ExceptionHandler를 사용 시에 주의할 점은 @ExceptionHandler에 등록된 예외 클래스와 파라미터로 받는 예와 클래스가 동일해야 한다는 것이다. 만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다.

java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...

 

 

ExceptionHandler는 파라미터로 HttpServletRequest나 WebRequest 등을 얻을 수 있으며 반환 타입으로는 ResponseEntity, String, void 등 자유롭게 활용할 수 있다. (더 많은 입력/반환 타입을 위해서는 공식 문서를 참고하도록 하자.)

@ExceptionHandler는 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외를 처리하는데 유용하다. 하지만 여러 컨트롤러에서 발생하는 예외라면 에러 처리 코드가 중복될 가능성이 매우 높다. 이러한 문제를 해결하기 위해 @ExceptionHandler가 구현된 공통 에러 처리 컨트롤러를 만들어 해결할 수도 있지만 컨트롤러에 상속이 사용된다는 점에서 더 좋은 방법을 찾을 필요가 있다.

 

 

 

 

[ @ControllerAdvice와 @RestControllerAdvice ]

Spring은 @ExceptionHandler의 한계를 극복하고자 전역적으로 예외를 처리할 수 있는 @ControllerAdvice와 @RestControllerAdvice 어노테이션을 각각 Spring3.2, Spring4.3부터 제공하고 있다. 두 개의 차이는 @Controller와 RestController와 같은데, @RestControllerAdvice는 @ControllerAdvice와 달리 @ResponseBody가 붙어 있어 응답을 Json으로 내려준다는 점에서 다르다. 아래 내용에서는 ControllerAdvice라고 줄여 설명을 진행하도록 하겠다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
    ...
}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {
    ...
}

 

 

ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 위에서 보이듯 ControllerAdvice 어노테이션에는 @Component 어노테이션이 있어서 ControllerAdvice가 선언된 클래스는 스프링 빈으로 등록된다. 그러므로 우리는 다음과 같이 전역적으로 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙여줌으로써 에러 처리를 위임할 수 있다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementFoundException.class)
    protected ResponseEntity<?> handleNoSuchElementFoundException(NoSuchElementFoundException e) {
        final ErrorResponse errorResponse = ErrorResponse.builder()
                .code("Item Not Found")
                .message(e.getMessage()).build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

 

 

ControllerAdvice는 특정 컨트롤러가 아니라 모든 컨트롤러들에서 동일하게 적용된다. 만약 특정 클래스에만 제한적으로 적용하고 싶다면 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.

Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 ControllerAdvice 클래스가 이를 상속받게 하면 된다. 하지만 에러 메세지는 반환하지 않으므로 스프링 예외에 대한 에러 응답을 보내려면 아래 메소드를 오버라이딩 해야 한다.

public abstract class ResponseEntityExceptionHandler {
    ...

    protected ResponseEntity<Object> handleExceptionInternal(
        Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
            
        ...
    }
}

 

 

만약 이 추상 클래스를 상속받지 않는다면 예외들은 ModelAndView 객체를 반환하는 DefaultHandlerExceptionResolver로 리다이렉트된다. 그래서 클라이언트가 일관되지 못한 에러 응답을 받을 것이므로 일관된 오류 응답을 반환하기 위해서는 ResponseEntityExceptionHandler를 상속받는 것이 좋다. 대표적으로 잘못된 URI로 후출하여 발생하는 NoHandlerFoundException 예외 등이 처리되지 않을 것이다.

우리는 이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
  • 별도의 try-catch문이 없어 코드의 가독성이 높아짐

 

이러한 이유로 ControllerAdvice를 이용한 방식은 일반적으로 가장 좋다고 평가받는다. 하지만 ControllerAdvice를 사용할 때에는 항상 다음의 내용들을 주의해야 한다.

  • 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
  • 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
  • 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.

 

여러 ControllerAdvice가 있을 때 @Order 어노테이션으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 에러를 처리할 수 있다. 그러므로 일관된 예외 응답을 위해서는 이러한 점에 주의해야 한다.

 

 

 

[ ResponseStatusException ]

Spring5에서는@ResponseStatus의 프로그래밍적 대안으로써 기본 에러 포맷(DefaultErrorAttributes) 기반으로 빠르게 에러를 반환할 수 있는 ResponseStatusException를 추가하였다. ResponseStatusException는 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있으며 언체크 예외(RuntimeException)을 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다. ResponseStatusException은 다음과 같이 사용할 수 있으며 우리가 만든 예외 클래스에 이를 상속받게 구현하면 원하는 status와 message를 설정할 수도 있다.

@GetMapping("/product/{id}")
public ResponseEntity<Product> getProduct(@PathVariable String id) {
    try {
        return ResponseEntity.ok(productService.getProduct(id));
    } catch (NoSuchElementFoundException e) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Item Not Found");
    }
}

 

 

@ResponseStatus와 동일하게 예외가 발생하면 ResponseStatusExceptionResolver가 에러를 처리한다. ResponseStatusException를 사용하면 다음과 같은 이점을 누릴 수 있다.

  • 기본적인 예외 처리를 빠르게 적용할 수 있으므로 손쉽게 프로토타이핑할 수 있다.
  • HttpStatus를 설정할 수 있고, 예외와의 결합도를 낮출 수 있다.
  • 불필요하게 많은 별도의 예외 클래스를 만들지 않아도 된다.
  • 프로그래밍 방식으로 예외를 직접 생성하므로 예외를 더욱 잘 제어할 수 있다.

 

하지만 그럼에도 불구하고 ResponseStatusException는 다음과 같은 한계점들을 가지고 있다.

  • 전역적인 @ControllerAdvice와 달리 일관되게 예외 처리하는 것이 어렵다.
  • 예외 처리 코드가 중복될 수 있다.
  • Spring 예외를 처리하는 것이 어렵다.

 

 

ResponseStatusException는 DefaultErrorAttributes를 사용하므로 이를 상속받는 클래스를 만들어 빈으로 등록하면 반환하는 에러 메세지를 수정할 수 있다. ResponseStatusException와 @ResponseStatus는 모두 ResponseStatusExceptionResolver가 처리하므로 @ResponseStatus에도 적용되며, 이를 통해 우리가 원하는 에러 메세지로 변경하는 것이 가능하다.

@Component
public class CustomTempErrorAttributes extends DefaultErrorAttributes {

    @Override
    public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
//        Throwable throwable = getError(webRequest);
//        Throwable cause = throwable.getCause();
        Map<String, Object> errorAttributes = new HashMap<String, Object>();
        errorAttributes.put("code", "NOT_FOUND");
        errorAttributes.put("message", "Item not found");
        return errorAttributes;
    }
}

 

 

 

 

3. Spring의 예외 처리 흐름


[ Spring의 예외 처리 흐름 ]

앞서 설명하였듯 다음과 같은 예외 처리기들은 스프링의 빈으로 등록되어 있고, 예외가 발생하면 순차적으로 다음의 Resolver들이 처리가능한지 판별한 후에 예외가 처리된다.

  1. ExceptionHandlerExceptionResolver: Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  2. ResponseStatusExceptionResolver: @ResponseStatus 또는 ResponseStatusException를 처리함
  3. DefaultHandlerExceptionResolver: 스프링의 예외들들을 처리함
    ex) HttpMediaTypeNotSupportedException, NoHandlerFoundException 등

 

 

 

  1. 예외가 던져지면 먼저 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는지 검사함
  2. 컨트롤러의 @ExceptionHandler에서 처리가능하다면 처리하고, 그렇지않으면 넘어감
  3. 컨트롤러의 @ExceptionHandler에서 처리가 불가능하다면 ControllerAdvice를 찾고 적합한 @ExceptionHandler가 있는지 검사함
  4. ControllerAdivce에서 처리가 가능하다면 처리하고, 그렇지않으면 넘어감
  5. ControllerAdvice에서 처리가 불가능하면 @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사함
  6. 맞다면 ResponseStatusExceptionResolver가 처리하고, 그렇지 않다면 DefaultHandlerExceptionResolver가 처리함

 

처음 Spring의 기본적인 예외 처리 방식에서 살펴보았듯 Spring은 BasicErrorController를 구현해두었다. ExceptionHandler나 ControllerAdvice처럼 직접 에러를 반환하는 경우에는 BasicErrorController를 거치지 않지만 @ResponseStatus, ResponseStatusException 등과 같이 에러 응답을 직접 반환하지 않는 경우에는 최종적으로 BasicErrorController를 거쳐 에러가 처리된다.

 

 

 

Spring은 매우 다양한 예외 처리 방법을 제공하고 있어 어떻게 에러를 처리하는 것이 최선(Best Practice)인지 파악이 어려울 수 있다. 위의 포스팅을 통해서 이제 ControllerAdvice를 이용하는 것이 일반적으로 최선임을 이해할 수 있었는데, 다음 포스팅에서는 어떻게 ControllerAdvice를 사용할 수 있는지 코드를 통해 살펴보도록 하자.

 

 

 

 

 

 

참고자료

 

 

 

관련 포스팅

  1. Spring의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)
  2. @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)

 

 

 

반응형
댓글
댓글쓰기 폼
  • 안녕하세요 항상 글 잘 읽고 있습니다.
    궁금한게 있는데용.. 각 글들에 들어가는 figure들은 어떻게 만드시나요
    이 글외에도 도표같은거나 다이어그램 등을 직접만드시는것 같은데
    ppt를 이용하는지 ... 아니면 혹시 유용한 툴이 있다면 알려주실 수 있나요?
    2022.01.18 17:59
  • 망나니개발자 직접 만드는 이미지들은 그냥 피피티 사용해서 만듭니다..!! 위에 다이어그램은 해외 사이트에서 가져온거입니다! 저는 딱히 유용한 도구들은 몰라서요ㅜㅜ 차트 그릴때는 draw.io 자주 사용했던 것 같습니다ㅎㅎ 2022.01.18 21:14 신고
  • 승화니' 내용 잘 보았습니다 :) 2022.04.01 12:16 신고
  • 망나니개발자 이글 개인적으로 열심히 썻는데 지금 보니 아쉬운 부분이 많네요ㅜㅜ 나중에 한번 개편해보겠습니다ㅎㅎ 감사합니다! 2022.04.02 00:12 신고
  • 공부하는개미 내용 진짜 알차네요.
    매번 글 잘 읽고있습니다!
    감사합니다
    2022.05.20 22:28 신고
  • 망나니개발자 안녕하세요! 부족한 글인데 읽어주셔서 감사합니다ㅎㅎ 앞으로도 자주 방문해주세요! 감사합니다:) 2022.05.23 20:40 신고
반응형
공지사항
Total
2,491,227
Today
264
Yesterday
5,743
TAG
more
«   2022/05   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31        
글 보관함