스프링 핵심 원리 - 고급편 1

Spring 정리 2022. 9. 20. 22:13

인프런 강의 60일차.

 - 스프링 핵심 원리 - 고급편 (김영한 강사님)

 - 반드시 한번은 정복해야할 쉽지 않은 내용들

 - 크게 3가지 고급 개념을 학습

  1. 스프링 핵심 디자인 패턴

     > 템플릿 메소드 패턴

     > 전략 패턴

     > 템플릿 콜백 패턴

     > 프록시 패턴

     > 데코레이터 패턴

  2. 동시성 문제와 쓰레드 로컬

     > 웹 애플리케이션

     > 멀티쓰레드

     > 동시성 문제

  3. 스프링 AOP

     > 개념, 용어정리

     > 프록시 - JDK 동적 프록시, CGLIB

     > 동작 원리

     > 실전 예제

     > 실무 주의 사항

 - 기타

     > 스프링 컨테이너의 확장 포인트 - 빈 후처리기

     > 스프링 애플리케이션을 개발하는 다양한 실무 팁

 - 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등

 

 1. 예제만들기 - 프로젝트 생성

  - https://start.spring.io

  - 프로젝트 선택 Project : Gradle Project

  - Language : Java

  - Spring Boot : 2.7.0

  - Group : hello

  - Artifact : advancde

  - Name : advanced

  - Package name : hello.advanced

  - Packaging : Jar

  - Java : 11

  - Dependencies : Spring Web, Lombok

* gradle.build 실행 후 아래 오류 발생 시 gradle-wrapper.properties에서 gradle 버전을 6.9 이하로 변경해서 다운로드하자. (gradle-6.9-all.zip)

 > Unable to find method 'org.gradle.api.artifacts.result.ComponentSelectionReason.getDescription()Ljava/lang/String;'. Possible causes for this unexpected error include: Gradle's dependency cache may be corrupt (this sometimes occurs after a network connection timeout.) Re-download dependencies and sync project (requires network)

 

 1.1. 예제만들기 - 프로젝트 생성

  - V0 학습을 위한 간단한 예제 프로젝트를 만들어보자.

  - 상품을 주문하는 프로세스로 가정하고, 일반적인 웹 애플리케이션에서 Controller Service Repository로 이어지는 흐름을 최대한 단순하게 만들어보자.

package hello.advanced.app.v0;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;


@Repository
@RequiredArgsConstructor
public class OrderRepositoryV0 {

    public void save(String itemId) {
        //저장 로직
        if (itemId.equals("ex")) {  //상품 ID 가 ex 면 예외 처리
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);    //상품을 저장하는데 1초가 걸린다고 가정
    }

    private void sleep(int miilis) {
        try {
            Thread.sleep(miilis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 - hello.advanced.app.v0.OrderRepositoryV0.java

 - @Repository : 컴포넌트 스캔의 대상이 된다. 따라서 스프링 빈으로 자동 등록된다.

 - sleep(1000) : 리포지토리는 상품을 저장하는데 약 1초 정도 걸리는 것으로 가정하기 위해 1초 지연을 주었다. (1000ms)

 - 예외가 발생하는 상황도 확인하기 위해 파라미터 itemId 의 값이 ex 로 넘어오면 IllegalStateException 예외가 발생하도록 했다.

 

package hello.advanced.app.v0;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepositoryV0 orderRepositoryV0;

    public void OrderItem(String itemId){
        orderRepositoryV0.save(itemId);
    }
}

 - hello.advanced.app.v0.OrderService.java

 - @Service : 컴포넌트 스캔의 대상이 된다.

 - 실무에서는 복잡한 비즈니스 로직이 서비스 계층에 포함되지만, 예제에서는 단순 저장 코드만 있다

 

 1.2. 로그 추적기 - 요구사항 분석

  - 애플리케이션에 병목이 자주 발생하고 있다. 어떤 부분에서 병목이 발생하는지, 그리고 어떤 부분에서 예외가 발생하는지를 로그를 통해 확인할 수 있도록 로그를 남겨두고, 개선 및 자동화 해보자

 

 * 요구사항

  - 모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력

  - 애플리케이션의 흐름을 변경하면 안됨

    > 로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨

  - 메서드 호출에 걸린 시간

  - 정상 흐름과 예외 흐름 구분

    > 예외 발생시 예외 정보가 남아야 함

  - 메서드 호출의 깊이 표현

  - HTTP 요청을 구분

    > HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함

    > 트랜잭션 ID (DB 트랜잭션X), 여기서는 하나의 HTTP 요청이 시작해서 끝날 때 까지를 하나의 트랜잭션이라 함

 

 * 예시

정상 요청
[796bccd9] OrderController.request()
[796bccd9] |-->OrderService.orderItem()
[796bccd9] | |-->OrderRepository.save()
[796bccd9] | |<--OrderRepository.save() time=1004ms
[796bccd9] |<--OrderService.orderItem() time=1014ms
[796bccd9] OrderController.request() time=1016ms
        
예외 발생
[b7119f27] OrderController.request()
[b7119f27] |-->OrderService.orderItem()
[b7119f27] | |-->OrderRepository.save()
[b7119f27] | |<X-OrderRepository.save() time=0ms
ex=java.lang.IllegalStateException: 예외 발생!
[b7119f27] |<X-OrderService.orderItem() time=10ms
ex=java.lang.IllegalStateException: 예외 발생!
[b7119f27] OrderController.request() time=11ms
ex=java.lang.IllegalStateException: 예외 발생!

 

 1.3. 로그 추적기 V1 - 프로토타입 개발

  - 애플리케이션의 모든 로직에 직접 로그를 남겨도 되지만, 그것보다는 더 효율적인 개발 방법이 필요하다.

  - 특히 트랜잭션ID와 깊이를 표현하는 방법은 기존 정보를 이어 받아야 하기 때문에 단순히 로그만 남긴다고 해결할 수 있는 것은 아니다.

  - 먼저 프로토타입 버전을 개발해보자. 아마 코드를 모두 작성하고 테스트 코드까지 실행해보아야 어떤 것을 하는지 감이 올 것이다.

  - 로그 추적기를 위한 기반 데이터를 가지고 있는 TraceId , TraceStatus 클래스를 만들어보자

 

package hello.advanced.trace;

import java.util.UUID;

public class TraceId {

    private String id;
    private int level;

    public TraceId() {
        this.id = createId();
        this.level = 0;
    }

    private TraceId(String id, int level) {
        this.id = id;
        this.level = level;
    }

    private String createId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }

    public TraceId createNextId(){
        return new TraceId(id, level + 1);
    }

    public TraceId createPreviousId(){
        return new TraceId(id, level - 1);
    }

    private boolean isFirstLevel(){
        return level == 0;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public int getLevel() {
        return level;
    }

    public void setLevel(int level) {
        this.level = level;
    }
}

 - hello.advanced.trace.TraceId.java

 - 로그 추적기는 트랜잭션ID와 깊이를 표현하는 방법이 필요하다.

   > 여기서는 트랜잭션ID와 깊이를 표현하는 level을 묶어서 TraceId 라는 개념을 만들었다.

   > TraceId 는 단순히 id (트랜잭션ID)와 level 정보를 함께 가지고 있다.

 - UUID : TraceId 를 처음 생성하면 createId() 를 사용해서 UUID를 만들어낸다. 너무 길어서 여기서는 앞 8자리만 사용한다.

   > 이 정도면 로그를 충분히 구분할 수 있다. 여기서는 이렇게 만들어진 값을 트랜잭션ID 로 사용한다

 - createNextId() : 다음 TraceId 를 만든다. level이 증가해도 트랜잭션ID는 같아야 한다.

   > createNextId() 를 사용해서 현재 TraceId 를 기반으로 다음 TraceId 를 만들면 id 는 기존과 같고, level 은 하나 증가한다. 

 - createPreviousId() : createNextId() 의 반대 역할을 한다. id 는 기존과 같고, level 은 하나 감소한다.

 - sFirstLevel() : 첫 번째 레벨 여부를 편리하게 확인할 수 있는 메서드

 

package hello.advanced.trace;

public class TraceStatus {

    private TraceId traceId;
    private Long startTimeMs;
    private String message;

    public TraceStatus(TraceId traceId, Long startTimeMs, String message) {
        this.traceId = traceId;
        this.startTimeMs = startTimeMs;
        this.message = message;
    }

    public TraceId getTraceId() {
        return traceId;
    }

    public void setTraceId(TraceId traceId) {
        this.traceId = traceId;
    }

    public Long getStartTimeMs() {
        return startTimeMs;
    }

    public void setStartTimeMs(Long startTimeMs) {
        this.startTimeMs = startTimeMs;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

 - hello.advanced.trace.TraceStatus.java

 - TraceStatus : 로그의 상태 정보를 나타낸다.

 - 로그를 시작하면 끝이 있어야 한다.

    > [796bccd9] OrderController.request() //로그 시작

    > [796bccd9] OrderController.request() time=1016ms //로그 종료

    > TraceStatus 는 로그를 시작할 때의 상태 정보를 가지고 있다. 이 상태 정보는 로그를 종료할 때 사용된다.

 - traceId : 내부에 트랜잭션ID와 level을 가지고 있다.

 - startTimeMs : 로그 시작시간이다. 로그 종료시 이 시작 시간을 기준으로 시작~종료까지 전체 수행 시간을 구할 수 있다.

 - message : 시작시 사용한 메시지이다. 이후 로그 종료시에도 이 메시지를 사용해서 출력한다.

 

 * TraceId , TraceStatus 를 사용해서 실제 로그를 생성하고, 처리하는 기능을 개발해보자

package hello.advanced.trace.helloTrace;

import hello.advanced.trace.TraceId;
import hello.advanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class HelloTraceV1 {

    private static final String START_PREFIX = "-->";
    private static final String COMPLETE_PREFIX = "<--";
    private static final String EX_PREFIX = "<X-";

    public TraceStatus begin(String message) {
        TraceId traceId = new TraceId();
        Long startTimeMs = System.currentTimeMillis();
        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTimeMs, message);
    }

    public void end(TraceStatus status) {
        complete(status, null);
    }
    public void exception(TraceStatus status, Exception e) {
        complete(status, e);
    }
    private void complete(TraceStatus status, Exception e) {
        Long stopTimeMs = System.currentTimeMillis();
        long resultTimeMs = stopTimeMs - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();
        if (e == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(),
                    resultTimeMs);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs,
                    e.toString());
        }
    }
    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append( (i == level - 1) ? "|" + prefix : "| ");
        }
        return sb.toString();
    }

}

 - hello.advanced.trace.helloTrace.HelloTraceV1.java

 - 실제 로그를 시작하고 종료할 수 있다. 그리고 로그를 출력하고 실행시간도 측정할 수 있다.

 - @Component : 싱글톤으로 사용하기 위해 스프링 빈으로 등록한다. 컴포넌트 스캔의 대상이 된다

 

 * 공개 메서드

 - 로그 추적기에서 사용되는 공개 메서드는 다음 3가지이다.

   1. TraceStatus begin(String message) : 로그를 시작한다.

       > 로그 메시지를 파라미터로 받아서 시작 로그를 출력한다. 응답 결과로 현재 로그의 상태인 TraceStatus 를 반환한다

   2. void end(TraceStatus status) : 로그를 정상 종료한다.

       > 파라미터로 시작 로그의 상태( TraceStatus )를 전달 받는다.

       > 이 값을 활용해서 실행 시간을 계산하고, 종료시에도 시작할 때와 동일한 로그 메시지를 출력할 수 있다.

       > 정상 흐름에서 호출한다

   3. void exception(TraceStatus status, Exception e)

       > 로그를 예외 상황으로 종료한다.

       >  TraceStatus , Exception 정보를 함께 전달 받아서 실행시간, 예외 정보를 포함한 결과 로그를 출력한다.

       > 예외가 발생했을 때 호출한다.

 

 * 비공개 메서드

   1. complete(TraceStatus status, Exception e

       > end() , exception() , 의 요청 흐름을 한곳에서 편리하게 처리한다. 실행 시간을 측정하고 로그를 남긴다

   2. String addSpace(String prefix, int level) : 다음과 같은 결과를 출력한다.

prefix: -->
level 0:
level 1: |-->
level 2: |   |-->

prefix: <--
level 0:
level 1: |<--
level 2: |   |<--

prefix: <X
level 0:
level 1: |<X
level 2: |   |<X

 

 * 테스트 작성

package hello.advanced.trace.helloTrace;

import hello.advanced.trace.TraceStatus;
import org.junit.jupiter.api.Test;

class HelloTraceV1Test {

    @Test
    void begin_end() {
        HelloTraceV1 trace = new HelloTraceV1();
        TraceStatus status = trace.begin("hello");
        trace.end(status);
    }

    @Test
    void begin_exception() {
        HelloTraceV1 trace = new HelloTraceV1();
        TraceStatus status = trace.begin("hello");
        trace.exception(status, new IllegalStateException());
    }
}

 - hello.advanced.trace.helloTrace.HelloTraceV1Test.java

 - 테스트 코드를 보면 로그 추적기를 어떻게 실행해야 하는지, 그리고 어떻게 동작하는지 이해가 될 것이다

 - begin_end() - 실행 로그

   > [d2fd2d18] hello

   > [d2fd2d18] hello time=5ms

 - begin_exception() - 실행 로그

   > [d2fd2d18] hello

   > [d2fd2d18] hello time=10ms ex=java.lang.IllegalStateException

 

 * 참고: 이것은 온전한 테스트 코드가 아니다. 일반적으로 테스트라고 하면 자동으로 검증하는 과정이 필요하다. 이 테스트는 검증하는 과정이 없고 결과를 콘솔로 직접 확인해야 한다. 이렇게 응답값이 없는 경우를 자동으로 검증하려면 여러가지 테스트 기법이 필요하다. 이번 강의에서는 예제를 최대한 단순화 하기 위해 검증 테스트를 생략했다.

 

 * 주의: 지금까지 만든 로그 추적기가 어떻게 동작하는지 확실히 이해해야 다음 단계로 넘어갈 수 있다. 복습과 코드를 직접 만들어보면서 확실하게 본인 것으로 만들고 다음으로 넘어가자.