반응형

[프로젝트 생성]

1. java, jdk, spring boot 버전 확인

2. 초기라이브러리 

- spring-web

- thymleaf

- lombok

 

[MTV pattern]

1. 의존성

1) @Component 와 @Autowired

스프링 bean 으로 등록하기 위해선 @Component 어노테이션을 달아주어야 한다. 만약 해당 객체 (Java는 모든것이 객체) 의 생성자를 만들때 의존성 주입이 필요하다면 @Autowired로 빌드 시 자동으로 주입되도록 한다.

생성자를 만드는 규칙은 3가지 이다

• setter 주입 (수정자 주입)
• field 주입
• 일반 메소드 주입

• 생성자 주입

권장되는 방법은 이중 "생성자 주입" 방식이다. 만약 생성자가 단 1개라면 @Autowired의 생략이 가능하다.

@Controller
@RequestMapping("/basic/items")
public class BasicItemController {
    private final ItemRepository itemRepository;

    @Autowired // 생성자가 단 한개이므로 생략 가능하다
    public BasicItemController(ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }
}

 

이때 required = False 처리를 하게 되면 주입 대상이 없을 경우 컴파일 에러가 발생하지 않지만 실제 주입 대상이 존재하지 않으면 호출되지 않는다.

@Controller
@RequestMapping("/basic/items")
public class BasicItemController {
    private final ItemRepository itemRepository;

    @Autowired(required=false)
    public BasicItemController(ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }
}

 

2. 생성자 

1) @RequiredArgsConstructor

Lombok의 어노테이션을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어준다.

이때 해당 객체는 생성자 주입시 필수 객체로 인지된다.

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
    private final ItemRepository itemRepository;
}

 

2) @AllArgsConstructor

모든 값을 필요로 하는 생성자를 만들어준다.

 

@Data
@AllArgsConstructor // 해당 객체를 만들려면 모든 값을 넘겨야 한다는 뜻
static class UpdateMemberResponse {
    private Long id;
    private String name;
}

위 객체를 만들기 위해서는 아래와 같이 생성해야 한다.

UpdateMemberResponse res = new UpdateMemberResponse(findMember.getId(), findMember.getName());

 

3) @NoArgsConstructor

기본생성자를 생성한다.

3. Controller

1) @Controller

스프링 빈으로 등록하기 위해 Component로 등록해야 하지만 Controller 어노테이션에 Component 어노테이션이 부착되어 있으므로 Contoller로 등록한다.

2) @RestController

@Controller 는 응답값이 html 파일로 전송되게 한다. @ResponseBody를 쓰면 되긴 하지만...

일반적으로 rest api 개발시 해당 동작은 불필요 하므로 간편하게 @RestController를 사용한다. (아래 예시)

- Controller사용과 메소드에 적용

@Controller
public class RequestBodyJsonController {
    private ObjectMapper objectMapper = new ObjectMapper();

    @ResponseBody
    @PostMapping("/hello")
    public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
        HelloData data = objectMapper.readValue(messageBody, HelloData.class);
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

 

- RestController 사용

@RestController
public class RequestBodyJsonController {
    private ObjectMapper objectMapper = new ObjectMapper();

    @PostMapping("/hello")
    public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
        HelloData data = objectMapper.readValue(messageBody, HelloData.class);
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

 

3) @RequestBody

HttpMessageConverter 사용 -> StringHttpMessageConverter 적용

개별 메소드에 적용할 수 있고, RestController를 사용함으로써 생략할 수도 있다.

 

4) @ResponseBody

- 모든 메서드에 @ResponseBody 적용
- 메시지 바디 정보 직접 반환(view 조회X)
- HttpMessageConverter 사용 -> StringHttpMessageConverter 적용

 

 

[Bean 등록]

1) @Configuration

직접 스프링 빈을 등록하기 위해서는 클래스를 구성정보/설정정보로 나타내주고 하위에 등록한다.

@Configuration // 구성정보, 설정정보
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
}

위 예제에서 memberService를 스프링 빈으로 등록하려는데 memberRepository에 대한 의존성을 갖고있다. 따라서 이것도 스프링 빈으로 등록해 주어야 한다.

@Configuration // 구성정보, 설정정보
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
    
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

 

[Validation]

값에 대한 검증은 필수이다. 모든걸 비즈니스 로직으로 처리하면 공수가 크고 유지보수가 어려우므로 스프링에서 제공하는 @Validated나 자바 표준 모듈인 @Valid를 사용한다. 둘중 어느걸 사용해도 상관 없다고 한다. 스프링 이라는 틀은 변하지 않을 것 같으므로 @Validated를 사용한다.

 

1) 직접 검증기 생성하기 (Validator 구현체 생성)

public interface Validator {
     boolean supports(Class<?> clazz);
     void validate(Object target, Errors errors);
}

위 인터페이스를 구현하는 나만의 Validator를 구현한다.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }
        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }
}

 

- 검증기 등록하기

@Component
 public class ItemValidator implements Validator {
     @Override
     public boolean supports(Class<?> clazz) {
         return Item.class.isAssignableFrom(clazz);
     }
    @Override
     public void validate(Object target, Errors errors) {
     }
 }

 

#1 특정 컨트롤러에 적용하기

// 적용할 클래스 안에서
@InitBinder
 public void init(WebDataBinder dataBinder) {
     log.info("init binder {}", dataBinder);
     dataBinder.addValidators(itemValidator);
 }

#2 모든 컨트롤러에 적용하기

@SpringBootApplication
 public class ItemServiceApplication implements WebMvcConfigurer {
     public static void main(String[] args) {
         SpringApplication.run(ItemServiceApplication.class, args);
}
     @Override
     public Validator getValidator() {
         return new ItemValidator();
     }
}

 

위 방법을 사용하게 될 경우 클래스 내의 InitBinder 어노테이션을 제거하여도 검증기로 등록되어 support 하는 클래스의 Validatior를 실행한다.

 

하지만 2번처럼 글로벌 설정을 하면 아래의 BeanValidator가 자동 등록되지 않고, 실제로 글로벌 설정을 직접 사용하는 경우는 드물다.

 

2) Bean Validation

해당 기능을 사용하기 위해선 의존관계 추가가 필요하다

 implementation 'org.springframework.boot:spring-boot-starter-validation'

  

Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 

Jakarta Bean Validation

- jakarta.validation-api : Bean Validation 인터페이스

- hibernate-validator : 구현체

 

package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
    private Long id;

    @NotBlank(message = "공백은 입력할 수 없습니다.")
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(9999)
    private Integer quantity;

    public Item() {}
    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

검증 애노테이션

- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null 을 허용하지 않는다.
- @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.

- @Max(9999) : 최대 9999까지만 허용한다.

 

 

- @Valid

javax.validation.@Valid 를 사용하려면 build.gradle` 의존관계 추가가 필요다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

- @Validated

@Validated 는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서
WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다 면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다. 여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate()` 가 호출된다.

 

@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v3/addForm";
        }
        //성공 로직
        Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
}

 

이때 @ModelAttribute에서도 바인딩에 대한 검증이 이뤄지는데 @ModelAttribute각각의 필드 타입 변환을 시도하고, 변환에성공한필드만BeanValidation적용된다.

 

- @ModelAttribute vs @RequestBody

- @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드 는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
- @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자 체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

 

[Filter]

1) Filter의 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

 

2) Filter의 생성

package hello.login.web.filter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
    FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();
        String uuid = UUID.randomUUID().toString();
        try {
             log.info("REQUEST  [{}][{}]", uuid, requestURI);
             chain.doFilter(request, response);
         } catch (Exception e) {
             throw e;
         } finally {
             log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
        }
}

Filter 인터페스를 구체화하여 나만의 Filter를 생성한다. 이때 Filter 인터페이스의 init과 doFilter 메소드를 오버라이딩 하여 로직을 구현한다.

 

주의해야 할 점은 doFilter 로직 안에서 필터 로직을 진행 한후 doFilter를 호출해야 한다는 것이다. 이때 다음 필터가 있다면 그 필터가 실행되고, 필터가 없다면 서블릿을 호출한다. 

chain.doFilter(request, response);

 

 

3) Filter 등록

만든 Filter를 스프링이 인식하여 실제 동작하게 하기 위해선 Config에 등록해야 한다.

package hello.login;
import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }
}

Filter를 등록할때에는 FilterRegistrationBean 클래스를 사용한다.

setOrder를 통해 실행될 필터의 우선순위를 설정하고, addUrlPatterns를 통해 어떤 경로 호출시 필터를 실행시킬지 결정한다.

4) Filter 특징

chain.doFilter를 통해 request와 response를 넘길때 해당 객체의 타입을 조작할 수 있다. Filter인터페이스의 기본 형은 Servlet~ 으로 사용할수 있는 메소드가 한정적이어서 doFilter 메소드 내에서 다운그레이드 하여 Http 객체로 변경처리하였다. 변경된 이 상태를 넘길 수 있다.

 

5) Filter와 에러처리

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

 

스프링MVC에서 오류 발생시 WAS로 에러가 전달되고, 해당 에러를 처리하기 위해 다시 Filter -> MVC의 흐름이 진행된다. 이럴때 정상 호출시에만 Filter만 호출되게 하고, 에러처리로 인한 실행에는 필터가 동작하지 않게 명시적으로 지정 할 수 있다.

@Bean
 public FilterRegistrationBean logFilter() {
     FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
     filterRegistrationBean.setFilter(new LogFilter());
     filterRegistrationBean.setOrder(1);
     filterRegistrationBean.addUrlPatterns("/*");
     filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
     return filterRegistrationBean;
}

Filter를 Bean으로 등록시 dispatcherType을 명시하지 않으면 default 값으로 REQUEST가 적용된다.

> DispatcherType 종류

- REQUEST : 클라이언트 요청
- ERROR : 오류 요청
- FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때 RequestDispatcher.forward(request, response)
- INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때 RequestDispatcher.include(request, response)
- ASYNC : 서블릿 비동기 호출

 

[Interceptor]

1) 인터셉터의 흐름

HTTP 요청 ->WAS-> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

 

2) 인터셉터의 생성

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    }
}

 

인터셉트를 구현하기 위해선 HandlerInterceptor 인터페이스를 구체화 한다.

 

- preHandle

: 컨트롤러 호출 전에 호출된다. (더 정확히는 핸들러 어댑터 호출 전에 호출된다.) 응답값이 "true" 이면 다음으로 진행하고, "false" 이면 더는 진행하지 않는다. "false" 인 경우 나머지 인터셉터는 물론이고, 핸들러 어댑터도 호출되지 않는다. 그림에서 1번에서 끝이 나버린다.

- postHandle

: 컨트롤러 호출 후에 호출된다. (더 정확히는 핸들러 어댑터 호출 후에 호출된다.) 이떄 컨트롤러 내부에서 에러가 발생했을 경우에는 호출되지 않는다.

- afterCompletion

: 컨트롤러의 에러 여부에 상관없이 뷰가 렌더링 된 이후에 호출된다.

 

3) 인터셉터의 등록

만든 인터셉터를 실행시키기 위해선 Config로 등록해야 한다. "WebMvcConfigurer" 가 제공하는 addInterceptors() 를 사용해서 인터셉터를 등록할 수 있다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
}
}

 

- order() : 인터셉터의 호출 순서를 지정한다. 낮을 수록 먼저 호출된다.
- addPathPatterns() : 인터셉터를 적용할 URL 패턴을 지정한다.

- excludePathPatterns() : 인터셉터에서 제외할 패턴을 지정한다.

 

 

[ArgumentResolver]

Filter와 Interceptor를 통해서도 인증을 체크할수 있지만 어노테이션을 활용하면 더욱 집약적으로 원하는 메소드에만 적용 할수 있다. 이때 ArgumentResolver를 활용한다.

인증된 유저만 접근할수 있는 컨트롤러를 예시로 들면 아래와 같다.

1) 사용 예시 : @Login

 @GetMapping("/")
 public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) { //세션에 회원 데이터가 없으면 home
     if (loginMember == null) {
         return "home";
     }
     //세션이 유지되면 로그인으로 이동
     model.addAttribute("member", loginMember); return "loginHome";
}

 

위에 적용된 @Login 어노테이션이 정상적으로 동작할 수있도록 구현하면 아래와 같다.

2) 어노테이션 구현

package hello.login.web.argumentresolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {}

- @Target(ElementType.PARAMETER) : 파라미터에만 사용
- @Retention(RetentionPolicy.RUNTIME) : 리플렉션 등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있음

 

3) 어노테이션을 실행시킬 resolver 구현

이제 이것이 실행될 수 있도록 resolver를 구현한다

package hello.login.web.argumentresolver;

import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Slf4j
public class LoginMemberArgumentResolver implements
        HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation =
                parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType =
                Member.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest)
        webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

- supportsParameter() : @Login  애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver 가 사용된다
- resolveArgument() : 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다. 여기서는 세션 에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준다. 이후 스프링MVC는 컨트롤러의 메서드를 호출 하면서 여기에서 반환된 member 객체를 파라미터에 전달해준다.

4) 스프링 컨테이너에 등록

마지막으로 생성한 LoginMemberArgumentResolver를 빈으로 등록한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
     public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
         resolvers.add(new LoginMemberArgumentResolver());
     }
}

 

 

[Exception]

REST API를 활용할때 효율적으로 에러처리 하는 방법위주

예외가 발생해 서블릿을 넘어 WAS 까지 전달되면 http status code는 500으로 처리된다.

1) 스프링부트 기본 오류 처리

스프링 부트의 기본 설정은 오류 발생시 /error를 오류 페이지로 요청하고, BasicErrorController는 이 경로를 기본으로 받는다.

이 기본경로는 .applications 파일에서 server.erorr.path 로 변경 가능하다.

 

스프링 부트는 BasicErrorController가 제공하는 기본 정보들을 활용하여 오류 API를 생성해준다. 노출되는 오류 메세지는 조절 가능하다.

server.error.include-binding-errors=always

server.error.include-exception=true

server.error.include-message=always

server.error.include-stacktrace=always

 

보안상 가릴껀 가리도록 한다!

 

2) API 예외 처리 : HandlerExceptionResolver

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다. 줄여서 ExceptionResolver 라 한다.

 

이때 ExceptionResolver로 적절한 에러처리를 하였어도 Interceptor의 postHandle()은 호출되지 않는다.

> 사용자 정의 예외 추가하기

- 나만의 Exception 종류를 추가한다.

package hello.exception.exception;

public class UserException extends RuntimeException {
    public UserException() {
        super();
    }

    public UserException(String message) {
        super(message);
    }

    public UserException(String message, Throwable cause) {
        super(message, cause);
    }

    public UserException(Throwable cause) {
        super(cause);
    }

    protected UserException(String message, Throwable cause, boolean
            enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

> HandlerExceptionResolver 구현하기

- 나만의 Resolver 만들고 에러종류에 따라 status code 지정하기

package hello.exception.resolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request,HttpServletResponse response, Object handler, Exception ex) {
    try {
        if (ex instanceof UserException) {
            log.info("UserException resolver to 400");
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            if ("application/json".equals(acceptHeader)) {
                 Map<String, Object> errorResult = new HashMap<>();
                 errorResult.put("ex", ex.getClass());
                 errorResult.put("message", ex.getMessage());
                 String result = objectMapper.writeValueAsString(errorResult);
                 response.setContentType("application/json");
                 response.setCharacterEncoding("utf-8");
                 response.getWriter().write(result);
                 return new ModelAndView();
             } else {
                 //TEXT/HTML
                 return new ModelAndView("error/400");
             }
        }
    } catch (IOException e) {
        log.error("resolver ex", e);
    }
    return null;
    }
}

발생한 에러 종류가 IllegalArgumentException 라면 status code 를 400으로 처리하게 한다.

 

- HandlerExceptionResolver의 return 값

해당 메소드에서 return 값의 종류에 따라 DispatcherServlet의 동작 방식이 달라진다.

- 빈 ModelAndView :new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.

- ModelAndView 지정 : ModelAndViewView , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.

- null : null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

 

- 구현한 Resolver 등록하기

WebMvcConfigurer를 통해 아래와 같이 등록한다.

/**
* 기본 설정을 유지하면서 추가 */
 @Override
 public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
     resolvers.add(new MyHandlerExceptionResolver());
}

 

3) API 예외 처리 - @ExceptionHandler : 클래스 처리

위에서 언급한 HandlerExceptionResolver의 리턴값은 ModelAndView 인데 REST API 프로젝트에서는 해당 반환값을 필요로 하지 않는다. 또한 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기도 어려웠는데. 이러한 단점을 보완하는 @ExceptionHandler 가 있다.

 

> 클래스 내에서 사용하는 @ExceptionHandler

 @Slf4j
 @RestController
 public class ApiExceptionV2Controller {
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     @ExceptionHandler(IllegalArgumentException.class)
     public ErrorResult illegalExHandle(IllegalArgumentException e) {
         log.error("[exceptionHandle] ex", e);
         return new ErrorResult("BAD", e.getMessage());
     }
 
 
     @ExceptionHandler
     public ResponseEntity<ErrorResult> userExHandle(UserException e) {
         log.error("[exceptionHandle] ex", e);
         ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
         return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
     }
     
    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }
    }

 

- 에러 캐치

Get 메소드로 호출된 컨트롤러 안에서 UserException 발생 시  @ExceptionHandler에 의해 캐치되어 적절하게 에러처리가 이뤄진다. 해당 에러처리 Handle 에서 status code는 400 이 되고 ResponseEntity 로 return 되어 정상 리턴이 이뤄진다.

 

- 에러 처리 우선순위

@ExcpetionHandler 메소드를 여러개 생성할 수 있는데 캐치하는 에러의 상하관계에 따라 자식클래스가 우선순위를 가지며 에러처리가 이뤄진다. @ExcpeitonHandler의 매개변수로 해당 메서드를 통해 처리될 에러class를 정의할 수 있는데 이곳에 복수개의 등록이 가능하다.

 @ExceptionHandler({AException.class, BException.class})

만약 해당 부분을 생략 할 경우 메서드의 파라미터의 예외 종류가 지정되어 처리된다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {

 

> @ExceptionHandler의 실행 흐름

- 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.

- 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.

- ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리 할 수 있는 @ExceptionHandler 가 있는지 확인한다.

- illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.

- @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.

 

 

4) API 예외 처리 - @ControllerAdvice : 모든 클래스

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의
컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.

 

#1 컨트롤러에서 예외 처리 코드 삭제

package hello.exception.exhandler;

import hello.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }
        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

 

#2 ControllerAdvice 처리 코드 작성

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandler(Exception e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}

RestControllerAdvice의 basePackage 파라미터를 통해 적용할 범위를 지정해 준다. 해당 범위 내에서 에러 발생시 위 파일내에서 처리 가능한 에러 종류라면 적절하게 처리되어진다.

> @ControllerAdvice

- @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능

을 부여해주는 역할을 한다.
- @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)

- @RestControllerAdvice @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다. @Controller , @RestController 의 차이와 같다.

 

 

반응형
반응형

[0] 스프링

  • 자바 언어 기반의 프레임워크
  • 자바 언어의 가장 큰 특징인 "객체 지향 언어"가 가진 강력한 특징을 살려내는 프레임워크
  • 스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크

스프링 활용 기술

핵심 기술: 스프링 DI 컨테이너, AOP, 이벤트, 기타
웹 기술: 스프링 MVC, 스프링 WebFlux
데이터 접근 기술: 트랜잭션, JDBC, ORM 지원, XML 지원
기술 통합: 캐시, 이메일, 원격접근, 스케줄링
테스트: 스프링 기반 테스트 지원
언어: 코틀린, 그루비
최근에는 스프링 부트를 통해서 스프링 프레임워크의 기술들을 편리하게 사용

 

[1] 스프링 부트

  • 스프링을 편리하게 사용할 수 있도록 지원, 최근에는 기본으로 사용
  • 단독으로 실행할 수 있는 스프링 애플리케이션을 쉽게 생성
  • Tomcat 같은 웹 서버를 내장해서 별도의 웹 서버를 설치하지 않아도 됨 손쉬운 빌드 구성을 위한 starter 종속성 제공
  • 스프링과 3rd party(외부) 라이브러리 자동 구성
  • 메트릭, 상태 확인, 외부 구성 같은 프로덕션 준비 기능 제공
  • 관례에 의한 간결한 설정

 

- 객체 지향 프로그래밍

4가지 특징 : 추상화, 캡슐화, 상속, 다형성

 

[3] IoC, DI, 컨테이너

1] IoC (Inversion of Control) : 제어의 역전

Appconfig 등장 이후로 프로그램의 제어 흐름은 AppConfig가 담당하고 구현객체는 자신의 로직을 실행하는 역할만 담당한다.

ex) OrderServiceImp 은 필요한 인터페이스들을 호출하지만 어떤 구현 객체들이 실행되는지는 알 수 없다 (AppConfig에서 객체가 생성되고 할당된다)

즉, 프로그램에 대한 제어 흐름에 대한 모든 권한이 AppConfiga에게 있는것인데 이처럼 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전 (IoC) 이라고 한다.

 

 

프레임워크 vs 라이브러리

- 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크가 맞다.

ex) JUnit

- 반면 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리이다.

ex) java 객체를 json이나 xml로 변환하려 할때 직접 호출하는 것은 라이브러리를 사용하는 것이다.

 

2] DI (Dependency Injection) : 의존관계 주입

OrderServiceImpl은 DiscountPolicy 인터페이스에 "의존" 하지만 실제 어떤 구현 객체가 사용될지는 모른다.

의존관계는 "정적인 클래스 의존관계"  "실행 시점에 결정되는 동적인 객체(인스턴스) 의존관계" 를 분리하여 생각해야 한다.

- DI란?

어플리케이션 실행 시점 (런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결되는 것을 DI (의존관계 주입) 이라고 한다.

 

  • 객체 인스턴스를 생성하고 그 참조갓을 전달해서 연결된다.
  • 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.
  • 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.

- 정적인 클래스 의존관계

class가 사용하는 import 코드만 보고 의존관계를 쉽게 판단할 수 있으며 프로그램을 실행하지 않아도 분석할 수 있다.

-> intelliJ 툴에서 볼수 있다 : Diargrams > show Diagram > show Dependency

 

BUT !! 이러한 정적인 클래스 의존관계로는 실제로 어떤 객체가 OrderServiceImpl에 주입 될지 알 수 없다.

 

- 동적인 객체(인스턴스) 의존관계

어플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존관계이다.

 

3] IoC컨테이너 혹은 DI컨테이너

AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해 주는 것 으로 의존관계 주입에 초점을 맞춰 최근에는 주로 DI 컨테이너 라고 부른다. 다른 이름으로는 어셈블러, 오브젝트 팩토리 등 ...

 

[4] 싱글톤 패턴과 싱글톤 컨테이너

1] 싱글톤 패턴

클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴

구현 예

package hello.core.singleton;
 public class SingletonService {
//1. static 영역에 객체를 딱 1개만 생성해둔다.
private static final SingletonService instance = new SingletonService();
//2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한 다.
     public static SingletonService getInstance() {
         return instance;
}
//3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다. private SingletonService() {
}
public void logic() { System.out.println("싱글톤 객체 로직 호출");
} }

단점

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. DIP를 위반한다.
  • 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다. 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 결론적으로 유연성이 떨어진다.
  • 안티패턴으로 불리기도 한다.

 

=> 이러한 싱글톤 패턴의 단점을 보완하고, 객체를 단 한개만 생성하게 해주는 것이 스프링의 싱글톤 컨테이너 이다

 

2] 싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리한다.

스프링 빈이 바로 싱글톤으로 관리되는 빈이다.

//Appconfig.java
@Configuration // 구성정보, 설정정보
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
}
// 싱글톤으로 생성되는지 확인하기
	@Test
    @DisplayName("스프링 컨테이너와 싱글톤") void springContainer() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        //1. 조회: 호출할 때 마다 같은 객체를 반환
        MemberService memberService1 = ac.getBean("memberService", MemberService.class);
        //2. 조회: 호출할 때 마다 같은 객체를 반환
        MemberService memberService2 = ac.getBean("memberService", MemberService.class);
        //참조값이 같은 것을 확인
        System.out.println("memberService1 = " + memberService1); System.out.println("memberService2 = " + memberService2);
        //memberService1 == memberService2
        assertThat(memberService1).isSameAs(memberService2);
    }

 

스프링은 99%는 싱글톤 빈을 사용하지만 가끔 싱글톤을 사용하지 않고 요청때마다 새로운 객체를 생성하는 기능도 제공한다.

 

3] 싱글톤 사용방식의 주의점

- 무상태로 하기

  • 객체를 딱 하나 생성해서 공유하기 때문에 싱글톤 객체는 상태를 유지하게 설계하면 안된다 즉, stateless (무상태) 해야 한다.
  • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
    특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다!
  • 가급적 읽기만 가능해야 한다.

- No 필드 -> 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용하기

필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다. 스프링 빈의 필드에 공유 값을 설정하면 정말 큰 장애가 발생할 수 있다!!!

 

4] @Configuration과 싱글톤

AppConfig에 @Configuration 어노테이션을 사용함으로써 싱글톤으로 스프링 빈이 등록되도록 한다.

해당 어노테이션을 사용하면 AppConfig 에 CGLIB가 붙어 싱글톤으로 동작하게 해준다.

만약 해당 어노테이션을 제거하고 @Bean만 사용할 경우 스프링 컨테이너에 스프링빈이 등록되는데 중복된 객체가 생성되어 싱글톤으로 생성되지 않는다.

 

[5] 컴포넌트 스캔

AppConfig 파일로 Configuration과 Bean 어노테이션을 활용해 설정정보를 작성하고 의존관계를 명시하는 방법이 있지만, 등록해야 할 빈이 많게 되면 코드의 유지보수가 복잡해진다. 이럴때 설정 정보가 없어도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔이라는 기능을 제공하는데 이를 활용할 수 있다. 또 의존관계도 자동으로 주입하는 @Autowired 라는 기능도 제공한다.

 

1] @Component 와 @Autowired

@Component : 빈으로 등록할 객체에 대해 해당 어노테이션을 붙인다

@Autowired : 객체가 등록될때 필요한 의존관계를 자동으로 주입해 준다. 생성자에서 여러 의존관계도 한번에 주입받을 수 있다.

 

2] 컴포넌트 스캔 기본 대상

  • @Component : 컴포넌트 스캔에서 사용
  • @Controller : 스프링 MVC 컨트롤러에서 사용
  • @Service : 스프링 비즈니스 로직에서 사용. 사실 @Service 는 특별한 처리를 하지 않는다. 대신 개발자들이 핵심 비즈니스 로직이 여기에 있겠구나~ 라고 비즈니스 계층을 인식하는데 도움이 된다.
  • @Repository : 스프링 데이터 접근 계층에서 사용하고, 데이터 계층의 예외를 스프링 예외로 변환해준다.
  • @Configuration : 스프링 설정 정보에서 사용 + 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 처리
    를 한다.

 

[6] 의존관계 @Autowired

의존관계는 스프링 빈에 등록된 객체에만 주입할수 있다!! @Component로 등록되어 있어야 한다는 뜻

1] 의존관계 주입 방법 4가지

- 생성자 주입

class 명과 동일한 생성자 메소드 작성하여 해당 메소드에 @Autowired 명시하기

생성자 호출시점에 딱 1번만 호출되는 것이 보장되며 "불변/필수" 의존관계에 사용된다.

@Component
public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;

    @Autowired
    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

- setter 주입 (수정자 주입)

setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 방법

"선택 / 변경" 가능성이 있는 의존관계에 사용한다.

@Component
public class MemberServiceImpl implements MemberService{

    private MemberRepository memberRepository;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
    	this.memberRepository = memberRepository;
    }
}

- field 주입

코드가 간결해서 많은 개발자들을 유혹하지만 외부에서 변경이 불가능해서 테스트 하기 힘들다는 치명적인 단점이 있다.
DI 프레임워크가 없으면 아무것도 할 수 없다.

=> 사용을 지양한다

@Component
public class MemberServiceImpl implements MemberService{

    @Autowired private MemberRepository memberRepository;
    
}

- 일반 메소드 주입

한번에 여러 필드를 주입 받을 수 있다. 일반적으로 잘 사용하지 않는다.

@Component
public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;

    @Autowired
    public void init(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

 

[7] 롬복 라이브러리

Getter, Setter, ToString, 생성자 주입 등 다양한 기능 지원

1] 생성자 주입 롬복 사용 :  @RequiredArgsConstructor

@RequiredArgsConstructor : final이 붙은 필드를 모아서 생성자를 자동으로 만들어준다.

사용 전

@Component
public class OrderServiceImpl implements OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

사용 후

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
}

 

 

[8] 빈 생명주기 콜백

객체의 생성과 초기화 단계적 예시

예시 클래스 생성

package hello.core.lifecycle;

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출 : " + url);
        connect();
        call("초기화 연결 메세지");
    }


    public void setUrl(String url) {
        this.url = url;
    }

    // 서비스 시작시 호출
    public void connect() {
        System.out.println("connect : " + url);
    }

    public void call(String message) {
        System.out.println("call : " + url + "message : " + message);
    }

    // 서비스 종료시 호출
    public void disconnect() {
        System.out.println("close : " + url);
    }
}

 

위 클래스 객체를 생성하고 초기화 하여 값 확인해보기

package hello.core.lifecycle;

import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

public class BeanLifeCycleTest {
    @Test
    public void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {

        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello-spring.dev");
            return networkClient;
        }
    }
}

 

위 테스트 코드 결과

/**
 * 생성자 호출 : null
 * connect : null
 * call : nullmessage : 초기화 연결 메세지
 * */

 

- 스프링 빈의 이벤트 라이프사이클

스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존관계 주입 -> 초기화 콜백 -> 사용 -> 멸전 콜백

 

- 초기화 콜백 : 빈이 생성되고, 빈의 의존관계 주입이 완료된 후 호출

- 소멸전 콜백 : 빈이 소멸되기 직전에 호출

 

- 스프링의 빈 생명주기 콜백 방법 3가지

스프링은 크게 3가지 방법으로 빈 생명주기 콜백을 지원한다.

 

  • 인터페이스(InitializingBean, DisposableBean)
  • 설정 정보에 초기화 메서드, 종료 메서드 지정
  • @PostConstruct, @PreDestroy 애노테이션 지원

 

1) 인터페이스(InitializingBean, DisposableBean)

package hello.core.lifecycle;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

public class NetworkClient implements InitializingBean, DisposableBean {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출 : " + url);
    }


    public void setUrl(String url) {
        this.url = url;
    }

    // 서비스 시작시 호출
    public void connect() {
        System.out.println("connect : " + url);
    }

    public void call(String message) {
        System.out.println("call : " + url + "message : " + message);
    }

    // 서비스 종료시 호출
    public void disconnect() {
        System.out.println("close : " + url);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        connect();
        call("초기화 연결 메세지");
    }

    @Override
    public void destroy() throws Exception {
        disconnect();
    }
}

 

위 상태로 코드 수정 뒤 테스트 코드를 실행하면 다음과 같다

생성자 호출 : null
connect : http://hello-spring.dev
call : http://hello-spring.devmessage : 초기화 연결 메세지
close : http://hello-spring.dev

Process finished with exit code 0

 

하지만 스프링 전용 인터페이스에 의존적이고, 초기화와 소멸 메소드의 이름을 변경할수 없고, 외부 라이브러리에 적용이 안되어서 잘 사용하지 않는 방법이다.

 

2) 설정 정보에 초기화 메서드, 종료 메서드 지정

1번방법의 implements를 삭제하고 초기화와 소멸 메소드 명을 커스텀한다.

public void init() throws Exception {
    connect();
    call("초기화 연결 메세지");
}

public void close() throws Exception {
    disconnect();
}

 

그리고 나서 해당 클래스가 등록된 Bean의 설정에 초기화와 종료 메소드를 지정한다

@Configuration
static class LifeCycleConfig {

    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        NetworkClient networkClient = new NetworkClient();
        networkClient.setUrl("http://hello-spring.dev");
        return networkClient;
    }
}

 

이후 테스트 코드를 실행하면 다음과 같다.

생성자 호출 : null
connect : http://hello-spring.dev
call : http://hello-spring.devmessage : 초기화 연결 메세지
close : http://hello-spring.dev

Process finished with exit code 0

 

위 방법을 사용하면 외부라이브러리에도 사용 가능하다.

 

3) @PostConstruct, @PreDestroy 애노테이션 지원

생성자와 소멸자 메소드에 어노테이션을 추가한다. 2번에서 추가한 Bean 설정은 제거한다.

@PostConstruct
public void init() throws Exception {
    connect();
    call("초기화 연결 메세지");
}

@PreDestroy
public void close() throws Exception {
    disconnect();
}

 

하지만 이 방법은 외부 라이브러리에는 적용하지 못한다. 

3번 방법을 가장 지향하지만, 외부라이브러리에 적용이 필요하다면 2번 방법을 사용하자!

 

[9] 빈 스코프

1] 스프링에서 지원하는 다양한 스코프

  • 싱글톤 : 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프. 호출할때마다 동일한 스프링빈이 호출된다.
  • 프로토타입 : 스프링 컨테이너가 빈의 생성과 의존곤계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프. 호출할때마다 새로운 빈이 호출된다. 빈이 요청되는 시점 스프링 컨테이너는 프로토타입 빈을 생성하고 필요한 의존관계를 주입한다. @PreDestroy가 동작하지 않는다.
  • 웹 관련 스코프
    • request : 웹 요청 in out 때까지 유지
    • session : 웹 생선 생성~종료 까지 유지
    • application : 웹 서블릿 컨텍스와 같은 범위로 유지
반응형
반응형

[1] 빌드하고 실행하기

사전작업 : spring 프로젝트를 생성한 상태. IDEL 에서 run 하여 포트연결이 확인 된 상태

// gradlew 파일이 위치한 곳으로 이동
> ll
build.gradle
gradle
gradlew
gradlew.bat
settings.gradle
src
// 아래 명령어 입력
> ./gradlew build
BUILD SUCCESSFUL in 4s
// 그러면 해당 경로에 build 폴더가 생성된다. build 파일 하위의 libs 폴더로 이동한다
> cd build/libs
> ll
staff   2.7K  4 21 22:42 hello-spring-0.0.1-SNAPSHOT-plain.jar
staff    20M  4 21 22:42 hello-spring-0.0.1-SNAPSHOT.jar
// 왜 -plain 이라는 jar 파일이 있는지는 모르겠음.... 일단 생성된 jar 파일을 실행시킨다
> java -jar hello-spring-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.4)
INFO 2389 --- [hello-spring] [           main] j.hellospring.HelloSpringApplication     : Started HelloSpringApplication in 1.823 seconds (process running for 2.374)

서버에서 프로젝트를 실행하려고 하면 jar 파일을 생성하여 서버에 올리고, 서버에서 jar 파일을 실행하도록 하면 된다.

만약 빌드가 제대로 이뤄지지 않은 경우 build 폴더를 삭제하고 다시 빌드해본다.

// 기존에 생성된 build 폴더를 삭제한다.
$ ./gradlew clean

// 이후 재 빌드한다.
$ ./gradlew build

// clean 과 빌드를 동시에 한다 (clean 후 빌드)
$ ./gradlew clean build

[2] DI 와 스프링 빈 등록하기

- DI (Dependency Injection , 의존성 주입)

DI를 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.

예) Repository Interface를 Service에서 사용하고 해당 인터페이스를 implement한 구현체를 A 에서 B로 바꾸면 DB설계가 변경되었는 상황에서 오직 Repository 만 새로 작성하고 다른 기존 코드는 수정하지 않아도 된다.

> @Controller @Service @Repository

// controller
@Controller
public class AContoller{
    private final AService aService;

    @Autowired // Autowired 해놓으면 컴파일 될때 해당 객체를 가져온다
    public AController(AService aService) {
        this.aService = aService;
    }   
}

// service
@Service
public class AService {
    private final ARepository aRepository;

    @Autowired
    public AService(ARepository aRepository) {
        this.aRepository = aRepository;
    }

// repository
@Repository
public class ARepository {
}

? 만약 @Service annotation이 붙어있지 않다면

Parameter 0 of constructor in hellospring.controller.AController required a bean of type 'hellospring.service.AService' that could not be found.

해당 에러가 발생한다. AService 라는 bean이 필요한데 찾을수 없다는 뜻. 따라서 annotation을 붙여주어 service 를 지정해준다.

> DI의 3가지 방법 : 필드 주입, setter 주입, 생성자 주입

1] 필드 주입

임의 조작이 불가능 하여 intelliJ에서는 변경을 권고한다. (노란줄 표시)

@Controller
public class AContoller{
    @Autowired private final AService aService;
}

2] setter 주입

command + N 으로 setter 생성

단점 : 누군가가 AController를 호출할때 serAService가 public 으로 호출되어 보안상 권고되지 않는다. (변경할수있게 되는 상태)

@Controller
public class AController {
    private AService aService;

    @Autowired
    public void setAService(AService aService) {
        this.aService = aService;
    }
}

3] 생성자 주입

- 위 예제에서 주입한 방법

- 가장 권장되는 방식

@Controller
public class AContoller{
    private final AService aService;

    @Autowired
    public AController(AService aService) {
        this.aService = aService;
    }   
}

- Spring bean을 등록하는 2가지 방법

1] 컴포넌트 스캔과 자동 의존관계 설정

- @Component 어노테이션을 사용하는 방법이 있다.

- 실행하는 어플리케이션과 동일한 package 이거나, 하위 package에 등록된 component만 spring bean으로 등록한다.

2] java 코드로 직접 스프링 빈 등록하기

실행 어플리케이션과 동일한 package 경로에 config 파일을 생성해 준다.

@Configuration
public class SpringConfig {
    @Bean
    public AService aService() {
        return new AService(aRepository());
    }

    @Bean
    public ARepository aRepository() {
        return new aRepository();
    }
}

위 코드를 작성하면 어플리케이션이 실행될때 @Configuration 어노테이션에 따라 @Bean으로 지정된 것들을 생성한다.

이전에 작성한 @Service와 @Repository 를 해제하고, @Service에서 @Autowired한것도 지웠을때 어플리케이션을 실행하면 정상적으로 실행된다.

// controller
@Controller
public class AContoller{
    private final AService aService;

    @Autowired // Autowired 해놓으면 컴파일 될때 해당 객체를 가져온다
    public AController(AService aService) {
        this.aService = aService;
    }   
}

// service
public class AService {
    private final ARepository aRepository;

    public AService(ARepository aRepository) {
        this.aRepository = aRepository;
    }

// repository
public class ARepository {
}

만약 위 config를 설정하지 않고 어노테이션만 지운 뒤 실행하면 필요한 bean을 찾지 못한다는 에러가 발생한다.

위 방법을 사용하면 repository 가 변경되었을때 (연결 DB 종류가 바뀐다던가..), config 파일에서 등록될 bean 부분만 코드를 수정해 주면 된다.

 

[3] 스프링 과 DB

1] h2

> homebrew 로 설치하기

참고로.. intel 맥북에어 메모리 8gb 스펙에서 brew 로 h2 다운로드 받으면 1시간걸린다...

brew install h2

 

> h2 실행하기

h2가 설치된 경로에 들어가서 h2 파일이 있는곳에서 접근권한을 부여해 주어야 한다.

// h2 > h2 > bin
chmod 755 h2.sh

이후 build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가

- 단위테스트와 통합테스트

- 순수 java 코드로만 테스트 작성 : 단위 테스트

- Spring application 이 실행되는 테스트 : 통합테스트

 

단위테스트를 우선적으로 작성할 줄 알면 좋다~!

 

- JPA

스프링 데이터 JPA 를 활용해 기본제공 쿼리 사용하여 코드를 간단하게 구현할수 있다.

 

[4] AOP

AOP가 필요한 때 예시

- 모든 메소드의 호출 시간을 측정하고 싶다면?
- 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern)

- 회원 가입 시간, 회원 조회 시간을 측정하고 싶다면?

 

 

- AOP: Aspect Oriented Programming

공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern) 분리

@Aspect 를 활용한다.

반응형
반응형

지난날 신규 프로젝트를 진행하면서 도메인 특성 상 많은 소셜미디어 플랫폼을 연동해야했다.

네이버 블로그와 인스타그램, 유투브를 타겟으로 하여 유저의 미디어 인사이트를 조회하고자 연동을 진행하였고,

이후 간편로그인 도입을 위해 카카오 연동도 추가로 진행하였다. 가장 연동 난이도가 낮았던 네이버를 예로 기록해보았다.

참고로 가장 어려웠던건 페이스북-인스타그램 이었다..............

 

# 소셜 로그인?

대부분의 소셜미디어에서 OAuth 인증을 통한 Third Party 앱의 인증 프로세스를 제공하고 있다. 각종 developer 사이트에 접속하면 이를 이용할수 있는 방법이 안내되어 있다.

 

[1] 개발자 등록하기

먼저 개발자센터를 이용하기 위해선 개발자로 등록해야 한다.

인증을 마치면 바로 애플리케이션 등록화면으로 넘어간다.

 

[2] 네이버 소셜로그인을 이용할 어플리케이션 등록하기

네이버 OAuth인증을 사용할 나의 어플리케이션을 등록한다.

참고로 애플리케이션은 여러개 등록이 가능하다. 아래 화면은 개발자로 기 등록된 상태에서 애플리케이션을 추가로 등록할때의 화면이다.

이때, 해당 서버 (이때 서버는 소셜 미디어 서버를 뜻한다) 의 전체 기능에 대한 권한에 접근할수 있을꺼라는 생각은 금물..!! 거의 왠만한 서비스에서 중요한 기능은 제공하지 않는 경우가 많다.

네이버의 경우도 위와 같은 기능에 한해서만 권한을 허용해준다. 여기서 "소셜 로그인" 기능을 위해 필요한 API는 "네이버 로그인" 이 되겠다.

 

네이버 로그인이라고 할지라도 그중에서 어떤 정보를 받을것인지 확인이 필요하다. 이때 꼭 필요한 정보만을 체크해야 한다!

나중에 검수를 진행하게 되는데 사용하지도 않는 정보를 요구했을 경우 이용이 제한될 수 있다. (담당자에게 전화온적도 있다... 검수하면서....)

 

그리고 나서 해당 서비스를 제공할 환경을 골라준다. 

이때 환경추가는 복수개로 등록이 가능하다. 서비스가 PC웹도있고, Mobile 웹도있고, 어플로도 존재하는 경우가 있을수 있으니 사용할 환경에 대한 정보를 모두 입력해 준다.

서비스 URL은 쉽게 서비스의 메인 url 을 입력하면 된다. (막혀있지 않은 공개된 url)

그리고 Callback URL은 네이버의 Authorization server를 통해 네이버 로그인창으로 로그인이 마무리 된후 보여줄 내 서비스의 페이지 url을 입력하면된다.

 

# 주의 ! Callback URL 갯수제한

이때 소셜로그인을 이동할수 있는 내 서비스의 페이지가 복수개라면 모두 입력해 주어야 한다.

여기서 저장되지 않은 callback URL을 나중에 간편로그인을 이용하면서 요청보내면 정상적으로 동작하지 않는다.

또한, callback url은 "최대 5개" 까지만 입력할 수 있으므로 유저 편의를 위한다고 여기저기서 소셜로그인이 가능하게 만들면 아주곤란해지므로 설계에 유의해야 한다.

 

본인도.. 5개 넘게 생각해뒀다가 제한에 걸려서 기능을 수정했던 경험이 있다 ㅎㅎ;;

 

[3] 어플리케이션 세팅하기

# 개요 : ClientID와 ClientSecret

어플리케이션을 등록하면 내 어플리케이션에 뜬다!

이때 ClientID와 ClientSecret 키가 발급된다. 딱봐도 한번에 노출 안된게 "노출하면 안되는거..!" 라는 느낌이 팍팍 드는 Client Secret은 "재발급" 이가능하므로, 혹시나 노출됐을경우 빠르게 재발급 하여 소중한 정보를 지키도록 하자!

개요 화면에서 보면 "네이버 로그인 검수요청" 이 보인다. 하지만 이를 진행하려면 아직 세팅할께 한참 남았으니 잠시 패스하고 다음 탭으로 넘어간다.

 

# API 설정

API 설정에서는 아까 어플리케이션 등록시에 설정했던 항목들을 볼 수 있다. 미쳐 설정을 놓친 부분이 있다면 이곳에서 다시 설정해 준다. 이때 "필수" 와 "추가"는 유저가 제공해야 할 정보에 대한 필수여부를 뜻하는데

 

"필수" 로 체크하면 유저가 이 서비스를 이용할시 반드시 제공해야 한다는 뜻이고,

"추가" 로 할 경우 서비스를 이용하는 유저가 선택적으로 해당 항목을 내 어플리케이션에 제공하겠다는 뜻이다.

 

만약 본인의 어플리케이션을 유저가 이용할때 필수로 받아야 하는 정보라면 "필수" 로 해놓고, 받아도 그만~ 안받아도 그만~ 이지만 받았을 경우 특별한 데이터 처리가 들어가는 경우엔 "추가" 로 받는다.

 

아까도 말했다싶이 사용하지도 않은 정보를 받으려 하면 검수과정에서 걸릴 수 있다!

 

+) 연락처 이메일!

여기서 "연락처 이메일 주소" 정보가 있다! 이는 해당 유저의 네이버 아이디가 아니다...!

네이버에 접속해 보면 "연락처이메일" 이라는 항목을 볼수 있는데 해당 정보를 제공받는 것으로 네이버 계정이지만 연락처이메일이 daum 이나 gmail 일수도 있다..

 

+) 로고 이미지

 

로고이미지를 등록해 준다. 이때 등록된 이미지는 유저가 우리 어플리케이션을 통해 네이버 로그인 진행시 정보제공 모달창에서 등장한다!

+) 연결 끊기

사용자가 내 어플리케이션에서 연동을 해제할 수 있지만, 네이버 계정관리의 Third party app 설정을 들어가 내 어플리케이션의 연결을 끊을 수도 있다! 이때 연결이 끊긴것에 대한 처리를 할 수 있도록 연결끊기 관련 Callback URL을 설정 할 수 있다.

 

# 네이버 로그인 검수 상태

해당 탭에서 검수에 필요한 자료를 첨부한다.

이 정보가 어디서 왜 필요한지에 대한 이미지 파일을 업로드 해야 한다.

참고로 사용하려는 기능이 "로그인" 이고, 로그인할때 제공받는 정보들이 있다면

 

로그인을 시작하는 단계부터 로그인 시 제공받은 정보가 활용되는 부분까지 전부 이미지 캡쳐해서 pdf 로 작성해야 한다.

 

+) 제공받은 정보가 노출되는 화면 캡쳐

 

+) 네이버로그인 API가 사용되는 흐름 캡쳐 : 총 12장 캡쳐

 

위 자료들이 준비 되었으면 다시 "개요" 화면으로 넘어가 "검수요청" 을 진행하면 된다

 

 

과정은 간단해 보이지만 정보 활용 내역을 검증하는데 굉장히 까다로운 심사로 수정에 수정을 거듭해야 한다.

아무래도 우리어플리케이션을 통해 네이버의 정보를 제공해주는거다 보니 까다로울 수 밖에 없기도 하다!

 

그만큼 개발하는 쪽도 개인정보에 대해 관리를 아주~ 잘~ 해주어야 겠다!

네이버 로그인 API를 활용한 기능이 활성화 되면 이런식으로 우리 어플리케이션을 통해 네이버 로그인을 사용하는 유저들의 이용량을 확인할 수 있다.

 

 

이상 소셜로그인중에서 그나마!! 담당자분도 한국분이시고, 문서 설명도 잘 되어있어

연동난이도가 가장~~~ 쉬웠던 네이버 소셜 로그인 이었다!

 

 

반응형
반응형
[목차]
[1] Python : GIL 에 대하여
[2] Python 에서 멀티쓰레드 구현 가능?
[3]Thread 성능 비교
[4] Python의 CPU-bound 동작에서 Thread 병목 현상
[5] 사내 프로젝트 개선 결과

 

최근 프로젝트 유지보수를 진행하면서 에러는 아니지만 기능개선할 부분들이 눈에 들어왔다. 그중에서도 가장 눈에 띈 것은 기존 로직의 속도 개선이다.

속도개선하는 방법으로 먼저 멀티쓰레드에 대해 알아보았다. 하지만 프로젝트가 어떤 환경으로 구성되어있는지에 따라 적용할수 있는 방식이 다를수 있으므로 대상 프로젝트에 기반하여 작업 방법을 모색해보았다.

 

[프로젝트 스펙]

- 언어 : Python

- 프레임워크 : Flask

- DB : NoSQL (MongoDB)

- Server : EC2

 

[1] Python : GIL 에 대하여

python은 언어 그 자체에서 GIL 이 적용되어 있다. GIL은 Global Interpreter Lock의 약자로 쉽게 말해서 언어자체적으로 싱글 쓰레드만을 지원한다는 뜻이다.

GIL이 생겨나게 된 배경에는 Python의 GC 동작 방식이 Reference count 방식이기 때문이다.

 

> GC

Garbage collector의 약자로 사용하지 않는 메모리를 반환하는 방식이다. python에서는 Reference count를 확인하여 GC가 동작하는데 Java의 경우 객체가 "도달가능한지" 를 판별하여 도달불가능한 객체인 경우 메모리를 회수하는 방식으로 동작한다.

 

> Reference count

참조횟수 카운트 방식이다. python에서는 모든 것이 객체이고 각 객체는 이것이 참조되는 횟수에 대한 데이터를 저장해 놓는다. 참조가 될때마다 이 수는 차감되고 최종 값이 0이 되면 해당 객체에 대한 메모리를 반환한다.

 

> 그래서 python 은?

멀티 쓰레드 환경이 될 경우 공유자원에 대한 reference count 관리가 복잡해진다. 뮤텍스와 같은 다양한 방식으로 이를 처리해 주어야 하는데, 이런 복잡한 상황을 예방하고자 애초에 python에는 GIL이 적용되어 한개 인터프리터에서는 한개 쓰레드만이 바이트코드에서 동작하도록 설계된 것이다.

[2] Python에서 멀티쓰레드 구현 가능?

그렇다고 해서 아예 멀티쓰레드 구현이 안되는건 아니다. 위에서도 말했다싶이 python에도 Treading 라이브러리가 존재한다. GIL이 적용되는 것은 로직이 CPU-bound 일때 적용되는 것이도 파일의 읽고쓰기와 같이 I/O - bound 작업에서는 해당 작업중 발생하는 대기시간 동안 다른 쓰레드가 동작할 수 있다.

즉, 어떤 로직인지 그 유형에 따라 적절하게 멀티쓰레드를 구현하면 원하는 개선효율을 얻을 수 있다.

[3] Thread 성능 비교

(1) CPU - bound

먼저 단순히 for loop를 이용한 로직에서 멀티쓰레드를 적용했을때와, 적용하지 않았을때를 비교해 보았다.

def only_count(n):
    while n > 0:
        n -= 1

big_num = 100000000 # 1억
start = time.time()
t1 = Thread(target=only_count, kwargs={"n": big_num//2})
t2 = Thread(target=only_count, kwargs={"n": big_num//2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 4.3975160121917725 초

start = time.time()
only_count(big_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 4.546915769577026

1억의 숫자를 while 문을 이용해 반복했을때 그냥 호출했을때와 쓰레드 2개로 했을때 소요시간에 유의미한 차이가 나지 않는다. 오히려 멀티쓰레드로 동작했을때가 더 오래 걸리는 경우도 있었다.

(2) I/O - bound : time.sleep()

python의 time 라이브러리를 사용해 sleep을 걸게 되면 I/O bound 동작이므로 쓰레드 적용에 대한 효과를 볼수 있다.

> time.sleep()

time.sleep() 메서드는 특정 시간 동안 프로세스를 중지시키는 함수이다 이 함수를 호출하면 현재 스레드의 실행이 지정된 시간 동안 일시 중지되는데 이는 CPU 자원을 사용하지 않고, 단순히 대기하고 있기 때문에 I/O 바운드 작업으로 분류된다.

def countdown_sleep(n, t_num=None):
    while n > 0:
        if t_num:
            print(f"Thread {t_num} >>> {n}")
        else:
            print(f"일반 >>> {n}")
        n -= 1
        time.sleep(1)
sleep_num = 10
start = time.time()
t1 = Thread(target=countdown_sleep, kwargs={"n": sleep_num//2, "t_num": 1})
t2 = Thread(target=countdown_sleep, kwargs={"n": sleep_num//2, "t_num": 2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 5.0233330726623535 초

start = time.time()
countdown_sleep(sleep_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 10.040925979614258

 

sleep 을 주어 I/O 바운드 작업이 있는 로직에서는 두개의 쓰레드로 동작했을때 단일 쓰레드 보다 소요시간이 절반으로 줄어드는 것을 확인할 수 있었다.

한가지 동작을 두개로 나누어 진행시켰을때 시간도 절반이 될것! 이라는 기대가 충족되었다.

쓰레드가 동작하는 과정을 보기 위해 countdown_sleep 함수에서 print를 찍어보았다.

# 쓰레드 동작
Thread 1 >>> 5
Thread 2 >>> 5
Thread 1 >>> 4
Thread 2 >>> 4
Thread 2 >>> 3
Thread 1 >>> 3
Thread 2 >>> 2
Thread 1 >>> 2
Thread 2 >>> 1
Thread 1 >>> 1

# 일반 동작
일반 >>> 10
일반 >>> 9
일반 >>> 8
일반 >>> 7
일반 >>> 6
일반 >>> 5
일반 >>> 4
일반 >>> 3
일반 >>> 2
일반 >>> 1

두개의 쓰레드가 동시에 진행된 것을 볼 수 있다.

 

[4] Python의 CPU-bound 동작에서 Thread 병목 현상

CPU-bound 로직에서 단일쓰레드와 멀티쓰레드 동작을 비교해 보면 두개 처리시간이 같을때도 있지만 심지어 멀티쓰레드로 했을때 더 오래 걸리는 경우도 있다.

 

5000의 숫자를 단일쓰레드로 처리할때와 절반씩 나눠 멀티쓰레드로 처리했을때 소요시간을 측정해보았다.

def only_count(n):
    while n > 0:
        n -= 1

big_num = 5000
start = time.time()
t1 = Thread(target=only_count, kwargs={"n": big_num//2})
t2 = Thread(target=only_count, kwargs={"n": big_num//2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 0.00043082237243652344 초

start = time.time()
only_count(big_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 0.00023221969604492188

멀티쓰레드로 구현했을때 단일 쓰레드보다 약 2배의 시간이 소요된 것을 확인할수 있는데 이는 python 의 GIL 방식으로 인해 쓰레드간 경쟁이 일어나 오버헤드가 발생했기 때문이다. 따라서 CPU-bound 로직에서의 멀티쓰레드는 구현하지 않는게 좋다. 

 

> 오버헤드 & 콘텍스트 스위칭

멀티프로세스 에서 cpu가 실행중인 프로세스를 변경할때 콘텍스트 스위칭이 발생한다. 이때 레지스터에 저장된 메모리를 변경하는데 시간이 소요되면서 오버헤드가 발생한다고 한다. 멀티쓰레드의 경우 한개 프로세스 안에서 스택영역을 제외한 다른 메모리는 모두 공유하기 때문에 멀티 프로세스의 오버헤드보단 덜 하지만, 그럼에도 멀티쓰레드에서도 오버헤드가 발생한다.

 

[5] 사내 프로젝트 개선 결과

개선하려는 로직은 외부 api를 호출해서 데이터를 컨버팅 하고, 그중 이미지 데이터는 바이트코드로 변환하여 다양한 이미지 폼 (jpg, png등)과 이미지 리사이징을 거쳐 AWS S3에 업로드까지 하는 기능이다.

이중에서 외부 api 호출과 이미지 리사이징 등 다양한 I/O-bound 로직이 존재하기에 멀티쓰레드로 구현했을때의 속도개선을 기대할 수 있었다.

 

실제로 데이터 6개 기준 단일 쓰레드로 동작 시 약 96초 걸리던 동작이 두개 쓰레드로 동작해보니 약 48초로 단축된 걸 확인할 수 있었다.

 

단, 멀티쓰레드 방식을 도입하기엔 현재 두가지 문제점이 존재한다.

 

첫번째, CPU 사용량

멀티쓰레드로 동작시 해당 서버의 CPU 사용량이 단일쓰레드 대비 2배로 올랐다. (원래 단일 쓰레드 동작시에 5%이하였음)

테스트에서는 한명의 유저가 6개의 데이터를 동작한 것이지만 실제 운영환경에서는 백명의 사용자가 100개의 데이터 처리를 한번에 요청할 경우 현재 서버 스펙이 감당하지 못할 수 도 있을꺼라는 생각이 들었다.

좀더 안정적인 서비스 운영을 위해 기존 서버를 오토스케일링하게 하거나, 보다 자세히 기능 이용률을 파악하여 적절한 쓰레드 분배를 하는것이 필요해 보인다.

두번째, 외부 API의 호출제한

멀티쓰레드로 동작하는 내부 소스중에서는 쇼핑몰 api 를 호출하는 과정이 있다. 해당 api는 초당 5회의 호출 제한이 있기 때문에 호출량 조절이 아주 중요한데, 현재 단일쓰레드일 때에도 과한 호출 시 호출제한에 걸리는 경우가 있다.

이 상태에서 멀티쓰레드까지 적용하고 나면 호출량이 동일 시간 대비 쓰레드 갯수만큼 늘어나기 때문에 더욱 불안정해 질 것이다. 외부 호출 로직을 제외한 나머지 부분에서 멀티쓰레드를 적용해야 할까 싶다.

 

 

위 두가지 문제점을 해결 하기 위해 전체 과정 중 유난히 긴 시간을 소요하는 일부 로직에서만 멀티쓰레드로 구현하거나, 아님 이 부분을 비동기로 처리하거나 하는 해결방안을 생각해 보았다.

 

 
반응형

+ Recent posts