Spring/SpringCore - advance

스프링 핵심원리( 심화 ) - 로그 추적기와 디자인패턴( 템플릿 메서드 패턴 )

_Jin_ 2025. 2. 19.

지금까지 요구사항을 충족하며 로그 추적기를 만들어 왔고,

파라미터를 넘기는 불편함과 동시성 문제를 해결하기 위해 ThreadLocal까지 도입하였다. 

 

그리고 로그 추적기를 도입한 코드의 상태는 아래와 같다. 

@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderControllerV3 {

    private final OrderserviceV3 orderservice;
    private final LogTrace logTrace;

    @GetMapping("/v3/request")
    public String request(String item) {

        TraceStatus status = null;

        try{
            status = logTrace.begin("OrderControllerV3.request()");
            orderservice.OrderItem(item);
            logTrace.end(status);
            return "ok";
        } catch (Exception e) {

            logTrace.exception(status, e);
            throw e; // 예외를 꼭 다시 던져주어야 한다.
        }
    }
}

그런데 로그 추적기를 도입하기 이전의 코드는 다음과 같았다. 

 

@RestController
@RequiredArgsConstructor
public class OrderControllerV0 {

    private final OrderserviceV0 orderservice;

    @GetMapping("/v0/request")
    public String request(String item) {
        orderservice.OrderItem(item);
        return "ok";
    }
}

 

본래 컨트롤러 계층에서 필요한 수행에 대해서 깔끔하게 사용된 형태였지만,

로그 추적기를 위해서 본래 계층에서의 목적보다 많은 부가적인 코드가 추가되었다. 

 

핵심 기능 vs 부가기능



어플리케이션에서 핵심 기능과 부가 기능은 다음과 같이 정리해볼 수 있다.

먼저, 핵심 기능은 해당 객체가 제공하는 고유의 기능이다.

예를 들어 orderService의 핵심 기능은 주문 로직에 해당할 것이다.

구체적으로 코드 수준에서 말하자면, orderService.orderItem()에 해당할 것이다.

 

한편, 부가 기능은 핵심 기능을 보조하기 위한 기능에 해당한다.

예를 들어 로그 추적이나 트랜잭션 등이 있다. 

이러한 부가 기능은 단독보다 핵심 기능과 함께 사용되는 경우가 많다. 

 

따라서 지금까지 만들었던 로그 추적기 역시도 부가 기능에 속한다고 볼 수 있다. 

그런데 부가 기능의 도입으로 핵심 기능보다 부가 기능을 처리하기 위한 코드가 더 많아 코드의 양과 복잡성을 야기하는 것은 바람직하지 않다.

 

게다가 만약, 컨트롤나 서비스 등의 핵심 기능을 담당하는 로직이 수백 개라면 모든 로직에 부가 기능을 전부 달아야 하는 번거로움과 더불어 수정이 필요하게 되는 경우 끔직한 상황이 발생한다. 

 

위와 같은 문제 상황에 대한 해결이 필요하다.

다시 로그 추적기 코드를 살펴보자.

 

컨트롤러 

 try{
       status = logTrace.begin("OrderControllerV3.request()");
       orderservice.OrderItem(item);
       logTrace.end(status);
       return "ok";
} catch (Exception e) {

       logTrace.exception(status, e);
       throw e; // 예외를 꼭 다시 던져주어야 한다.
}

서비스

try{
      status = logTrace.begin("OrderServiceV3.OrderItem()");
      orderRepo.save(item);
      logTrace.end(status);
} catch (Exception e) {
      logTrace.exception(status, e);
      throw e;
}

 

각 코드는 다른 계층에서 사용하짐나, 

핵심 기능을 담당하는 로직이 다를 뿐 동일한 구조를 보이고 있다. 

 

부가 기능을 담당하는 로직이 핵심 로직과 더불어 반복을 보이고 있는 것이다.

 

단순하게 반복되는 코드를 별도의 메서드로 뽑아서 사용하면 될 것이라고 생각할 수 있다.

그러나 try ~ catch와 더불어 핵심 기능이 중간에 있기에 메서드로 추출하여 사용하는 것이 쉽지 않다.

 

그렇다면, 어떻게 반복되는 코드를 관리할 수 있을까?

좀 더 좋은 구조와 설계가 필요한 순간이다.

이에 대한 방법 중 하나가 템플릿 메서드 패턴을 사용하는 것이다.

 

템플릿 메서드 패턴은 변하는 것과 변하지 않는 것을 분리하여 사용할 수 있도록 도와주는 디자인 패턴이다.

 

다음의 예를 통해서 이해해보자.

@Slf4j
public class TemplateMethodTest {
	@Test
	void templateMethodV0() {
		logic1();
		logic2();
	}
	private void logic1() {
			 
		long startTime = System.currentTimeMillis();
			 
		//비즈니스 로직 실행
		log.info("비즈니스 로직1 실행");
			 
		//비즈니스 로직 종료
		long endTime = System.currentTimeMillis();
		long resultTime = endTime - startTime;
		log.info("resultTime={}", resultTime);
		}
		
		private void logic2() {
		 
		long startTime = System.currentTimeMillis();
		//비즈니스 로직 실행
    log.info("비즈니스 로직2 실행");
       
		//비즈니스 로직 종료
		long endTime = System.currentTimeMillis();
		long resultTime = endTime - startTime;
    log.info("resultTime={}", resultTime);
   }
}

logic1()과 logic2()는 시간을 측정하는 부분과 비즈니스 로직이 함께 존재한다.

앞서, 문제가 되었던 로직과 같은 형태인 것이다.

 

이제 템플릿 메서드 패턴을 적용하여 변하는 부분과 변하지 않는 부분을 분할해보자.

이 구조를 표현하면 아래와 같다.

 

이와 같은 구조를 표현하기 위해서는 아래와 같이 코드를 구현할 수 있다. 

@Slf4j
public abstract class AbstractTemplate {
	 public void execute() {
		 long startTime = System.currentTimeMillis();
		 //비즈니스 로직 실행
		 call(); //상속
		 //비즈니스 로직 종료
		 long endTime = System.currentTimeMillis();
		 long resultTime = endTime - startTime;
     log.info("resultTime={}", resultTime);
   }
 
 protected abstract void call();
 
 }

코드를 자세하게 분석해보자.

반복적으로 변하지 않는 부분인 시간 측정 로직이 들어갔다.

 

추상 클래스로 정의하여 이를 상속받는 객체마다 구현이 필요한 서비스 로직 부분은 call() 추상 메서드로 정의하였다.

즉, 상속받는 객체마다 템플릿 내부에서 변하는 부분은 call() 추상 메서드로 오버라이딩하여 처리하고 변하지 않는 부분은 부모 클래스에서 템플릿 형태로 사용하는 것이다. 

 

이제 이 클래스를 상속받은 자식 객체와 더불어 사용하는 코드를 보자.

 @Slf4j
 public class SubClassLogic1 extends AbstractTemplate {
 @Override
 protected void call() {
        log.info("비즈니스 로직1 실행");
    }
 }

 

@Slf4j
public class SubClassLogic2 extends AbstractTemplate {
@Override
protected void call() {
        log.info("비즈니스 로직2 실행");
    }
 }

먼저, 추상 클래스를 상속받은 두 구현체에서 추상 메서드를 오버라이디 하였다.

 

@Test
void templateMethodV1() {
AbstractTemplate template1 = new SubClassLogic1();
   template1.execute();
AbstractTemplate template2 = new SubClassLogic2();
   template2.execute();
}

그리고 해당 객체를 생성하여 실행한 결과

 

각 구현체에서 오버라이딩한 로직과 더불어 부모 클래스에서 정의 로직이 수행되는 모습으로 아래와 같은 흐름을 따른다.

 

 

만약 SubClassLogic1 이나 SubClassLogic2처럼 클래스를 만들어야 하는 부분에 있어, 

개선이 필요하다면 익명 내부 클래스를 사용하여 보완할 수도 있다. 

 

/**
 * 템플릿 메서드 패턴, 익명 내부 클래스 사용
 */
 @Test
 void templateMethodV2() {
 
 AbstractTemplate template1 = new AbstractTemplate() {
 
 @Override
 protected void call() {
            log.info("비즈니스 로직1 실행");
        }
    };
    
 log.info("클래스 이름1={}", template1.getClass());
 template1.execute();
    
 AbstractTemplate template2 = new AbstractTemplate() {
 
 @Override
 protected void call() {
            log.info("비즈니스 로직1 실행");
        }
    };
    
 log.info("클래스 이름2={}", template2.getClass());
 template2.execute();
 }

 

실행 결과는 다음과 같다. 

 

생성된 로그를 보면 클래스 객체를 표현하는 로그에서 $1이나 $2가 붙은 모습을 볼 수 있다.

이는 자바가 임의로 만들어주는 익명 내부 클래스의 경우 $~ 표시가 붙는 것이다.]

 

지금까지 템플릿 메서드 패턴의 골자에 대해서 파악하였으니,

만든 어플리케이션의 로그 추적기에 템플릿 메서드 패턴을 적용해보자.

public abstract class AbstractTemplate<T> {
    
    private final LogTrace trace;

    public AbstractTemplate(LogTrace trace) {
        this.trace = trace;
    }

    public T execute(String message) {

        TraceStatus status = null;

        try {
            status = trace.begin(message);
            //로직 호출
            T result = call();
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
    }
}

protected abstract T call();
}

먼저 LogTreace를 필드 멤버로 가진 로그 추적기 역할을 지닌 추상 객체를 정의한다.

 

그리고 아래는 어플리케이션 로직에 적용한 형태이다.

 

Controller 

@RestController
@RequiredArgsConstructor
public class OrderControllerV4 {
    private final OrderServiceV4 orderService;
    private final LogTrace trace;
    @GetMapping("/v4/request")
    public String request(String itemId) {
        AbstractTemplate<String> template = new AbstractTemplate<>(trace) {
            @Override
            protected String call() {
                orderService.orderItem(itemId);
                return "ok";
            }
        };
        return template.execute("OrderController.request()");
    }
}

 

Repository

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV4 {
    private final LogTrace trace;
    public void save(String itemId) {
        AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
            @Override
            protected Void call() {
                //저장 로직
                if (itemId.equals("ex")) {
                    throw new IllegalStateException("예외 발생!");
                }
                sleep(1000);
                return null;
            }
        };
        template.execute("OrderRepository.save()");
    }
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

Service

@Service
@RequiredArgsConstructor
public class OrderServiceV4 {
    private final OrderRepositoryV4 orderRepository;
    private final LogTrace trace;
    public void orderItem(String itemId) {
        AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
            @Override
            protected Void call() {
                orderRepository.save(itemId);
                return null;
            }
        };
        template.execute("OrderService.orderItem()");
    }
}

 

이를 실행한 결과 로그가 제대로 생성된 모습이다.

 

이를 통해서 변하는 부분과 변하지 않는 부분의 코드를 명확하게 분리하였고,

로그를 출력하는 템플릿 부분에 해당하는 부분은 AbstractTemplate에 구현하였다.

 

한편, 변하는 부분은 자식 클래스에서 구현하도록 위임하였다.

 

지금까지 로그 추적기를 개발한 코드의 변경은 다음과 같다. 

//OrderServiceV0 코드
public void orderItem(String itemId) {
	orderRepository.save(itemId);
}
 
//OrderServiceV3 코드
public void orderItem(String itemId) {
	TraceStatus status = null;
 	try {
        status = trace.begin("OrderService.orderItem()");
        orderRepository.save(itemId); //핵심 기능
        trace.end(status);
    } 
	catch (Exception e) {
        trace.exception(status, e);
 	throw e;
    }
 }


//OrderServiceV4 코드
AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
    @Override
 	protected Void call() {
        orderRepository.save(itemId);
 	return null;
    }
 };
 template.execute("OrderService.orderItem()");

 

OrderServiceV0 : 핵심 기능만 있다.
OrderServiceV3 : 핵심 기능과 부가 기능이 함께 섞여 있다.
OrderServiceV4 : 핵심 기능과 템플릿을 호출하는 코드가 섞여 있다.

 

v4까지 오면서 로그 추적기라는 부가 기능을 사용하기 위해 템플릿 메서드 패턴을 적용하였고,

v3에 비하여 핵심 기능에 좀 더 집중할 수 있게 되었다.

 

또한 이를 통해서 변경에 유연하게 대응할 수 있게 되었다.

만약 v3의 코드와 같이 사용하는 경우 로그를 출력함에 변경이 생긴다면,

로그 추적기를 사용한 모든 코드 전부에 대한 변경이 필요하게 된다.( 끔찍한 상황이다. )

 

하지만, 템플릿 메서드 패턴을 적용한 v4와 같은 경우 로그에 대한 변경이 생기는 경우

AbstractTemplate만 변경하면 된다.

 

이를 객체 지향적인 관점에서 바라본다면, 단일 책임 원칙(SRP)을 준수한 것이다.

정리하자면 부모 클래스에 필요한 알고리즘의 골격( 템플릿 )을 정의하고,

일부 변경이 필요한 부분에 대해서는 자식 클래스가 정의하는 것이 템플릿 메서드 패턴의 핵심이다.

결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결한 것이다.

 

하지만, 템플릿 메서드 패턴이 장점만 있는 것은 아니다.

템플릿 메서드 패턴은 추상 클래스를 사용하기에 상속을 사용한다. 

따라서 상속을 통해 오는 단점들을 가지고 있다. 

( 상속을 통한 구현에는 결합도가 높아지고, 단일 상속의 한계와 같은 다양한 단점들이 존재한다. )

 

또한, 지금 구조에서 자식 클래스가 부모 클래스의 기능을 사용하는 경우가 존재하지 않음에도 부모 클래스에 의존해야 한다. 

흔히 상속보단 조합을 사용하라는 말이 있듯, 상속을 통한 구현에는 많은 단점들이 존재한다. 

 

즉, 명확한 is-a 관계가 아니라면 템플릿 메서드 패턴을 사용하는 것도 나중에는 개선이 필요한 설계일 가능성이 농후하다는 것이다.

 

그렇다면, 템플릿 메서드 패턴의 상속을 통한 구현을 해결할 방법은 없을까?

다행히도 이를 보완한 패턴이 존재한다.

바로 전략 패턴이다. 다음 포스팅부터는 이에 대해 학습하고 정리해보겠다.

 

댓글