[Spring] 코드 개선 (1) - 템플릿 콜백 패턴을 이용한 미들웨어 통신 코드 개선

김희정·2024년 3월 11일
0

Spring

목록 보기
16/16

💎 들어가며

이번 포스팅에서는 템플릿 콜백 패턴을 이용여 미들웨어 통신 부분을 모듈화하여 코드를 개선한 사례에 대해 소개합니다.


1. Template Callback Pattern

Definition

템플릿 콜백 패턴(Template Callback Pattern)전략 패턴(Strategy Pattern)의 변형된 패턴으로 GoF 공식 패턴이 아닌 스프링에서 전략 패턴을 변형으로 사용된 패턴입니다.

Strategy Pattern

전략 패턴(Strategy Pattern)은 디자인 패턴 중 행위 패턴에 해당하며, 알고리즘을 정의하고 각각을 캡슐화하여 상호교환이 가능하게 만드는 패턴입니다.

변하지 않는 부분을 Context, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 알고리즘을 교환 가능한 상태로 변경할 수 있게 하는 패턴입니다.

Template Callback Pattern

전략 패턴에서 Context가 템플릿 역할을 하고, Strategy 부분이 콜백으로 넘어온다고 생각하면 됩니다. 템플릿 메소드 패턴과 전략 패턴의 강점이 결합된 패턴이라고 생각하면 됩니다.

Callback

💡 콜백 (callback)

프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.


2. Example

2.1 Strategy Pattern

Strategy 구현

핵심 로직(Strategy, 변하는 부분)을 구현합니다.

전략으로 사용할 기능을 Interface로 생성합니다.

public interface Strategy {
    void call();
}

전략을 구현합니다.

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class StrategyLogic1 implements Strategy {
    @Override
    public void call() {
        log.info("전략 메소드1 실행");
    }
}

@Slf4j
public class StrategyLogic2 implements Strategy {
    @Override
    public void call() {
        log.info("전략 메소드2 실행");
    }
}

Context 구현

공통 로직(Context, 변하지 않는 부분)을 개발합니다.

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Context {
	private Strategy strategy;
    
    public Context(Strategy strategy){
    	this.strategy = strategy;
    }
    
    public void execute() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        strategy.call();
        // 비즈니스 로직 종료

        long endTime = System.currentTimeMillis();
        log.info("time = {}ms", endTime - startTime);
    }
}

Test

import org.junit.jupiter.api.Test;

public class ContextTest {
    /**
     * 전략 패턴 사용
     */
    @Test
    public void strategyTest() {
        StrategyLogic1 logic1 = new StrategyLogic1();
        Context context1 = new Context(logic1);
        context1.execute();

        StrategyLogic2 logic2 = new StrategyLogic2();
        Context context2 = new Context(logic2);
        context2.execute();
    }
}

2.2 Template Callback Pattern

Template Callback PatternStrategy Pattern의 Context에 Strategy를 저장하지 않고, Context를 실행하는 메소드 호출 시점 전략을 넘겨주는 (Callback) 형태입니다.

Context 구현

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Context {
	public void execute(Strategy strategy) {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        strategy.call();
        // 비즈니스 로직 종료

        long endTime = System.currentTimeMillis();
        log.info("time = {}ms", endTime - startTime);
    }
}

Test

import org.junit.jupiter.api.Test;

public class ContextTest {
    /**
     * 전략 패턴 사용
     */
    @Test
    public void strategyTest() {
        Context context1 = new Context(logic1);
        context1.execute(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직1 실행");
            }
        });

        Context context2 = new Context();
        context2.execute(() => {
        	log.info("비즈니스 로직2 실행");
        });
    }
}

3. 미들웨어 통신 코드 개선

3.1 코드 개선 프로세스

Template Callback Pattern의 주요 쟁점은 공통 로직과 핵심 로직을 분리하여 중복 코드를 제거하는 것입니다.

  1. 로직 구현
  2. 공통 로직(변하지 않는 부분)과 핵심 로직(변하는 부분)을 분리
  3. 코드 적용

처음부터 확장성이 있는 코드를 구현하는 것은 공통 로직과 핵심 로직이 정확히 파악이 안되기 때문에 어렵습니다. 우선 로직을 구현한 뒤에 리팩토링해 나가는 것이 중요합니다.

3.2 사례

애플리케이션에 생체인식 미들웨어를 통해 생체 템플릿을 받아오는 기능이 있었습니다.

생코딩으로 이루어진 코드

미들웨어 요청부터 응답받아 결과를 처리하는 50줄 이상의 코드가 모두 모듈화가 되어있지 않았습니다. 기능이 추가될 때마다 이러한 코드가 요청 별로 50줄 이상씩 있는 것입니다.

핵심 기능은 동일하나 받은 응답 값을 처리하는게 관건인데, 기능을 추가할 때마다 이러한 보일러플레이트 코드가 몽땅 추가되니 코드가 아름답지 못하다는 생각이 들었습니다.

모듈화할 순 없을까?

이러한 보일러플레이트 코드를 제거하기 위해 모듈화를 하게되었습니다. 하지만 모듈화를 하려면, 응답 값을 처리하는 부분이 문제였습니다.

"일부 부분만 변경되는데, 그부분만 처리 방식을 정의할 순 없을까?"에서 시작하여 조사하던 중 템플릿 콜백 패턴을 알게 되었습니다.


3.3 코드 개선

기능

코드 개선을 위해 코드를 모두 작성한 뒤, 공통 로직과 핵심 로직을 분리하였습니다.
기능을 적어보면 아래와 같습니다.

공통 로직

  • Socket 연결 후, Packet 전송 및 응답
  • 로그 남기기
  • Exception 처리
  • 공통 응답 값 생성

핵심 로직

  • 받은 데이터 별 응답 변환

핵심 로직

Strategy로 사용할 인터페이스 응답 결과 처리기 PacketProcessor를 정의하였습니다. 응답 값은 때에 따라 변할 수 있기 때문에 Generic 타입으로 설정했습니다.

import java.util.Map;

public interface PacketProcessor<T> {
    T process(Map<String, Object> params);
}

공통 로직

공통적으로 사용할 코드를 작성했습니다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;

@Slf4j
public class PacketUtil {
    /**
     * [PacketUtil] 생체 템플릿 요청
     *
     * @param connProps 연결 정보
     * @param command   명령
     * @param processor 응답 전처리기
     * @return 응답 결과
     */
    public static ResponseEntity<?> connect(ConnProps connProps, 
                                            BioCommand command, PacketProcessor<?> processor) {
        try {
            // 1. 요청 패킷 생성
            Packet request = Packet.builder()
                    .command(command)
                    .type(CommandType.REQUEST)
                    .build();

            // 2. 소켓 연결후 요청 패킷 전송 및 응답 결과 반환
            Packet response = sendAndReceive(connProps, request);
            
            // 5. 로깅
            StringBuilder sb = new StringBuilder();
			sb.append("> --- Packet Connection --- <");
            sb.append(String.format("\n>[req] Packet: %s", request));
            sb.append(String.format("\n>[res] Packet: %s", response));
            log.debug(sb.toString());

            // 3. 응답값 생성 - 응답 타입이 에러일 경우
            if (response.getType().equals(CommandType.ERROR)) {
                return ResponseEntity
                        .badRequest()
                        .body(response.getDescription());
            }

            // 3. 응답값 생성 - 응답 타입이 정상일 경우
            else if (response.getType().equals(CommandType.RESPONSE)) {
            	// 4. 응답 값 처리기로 위임
                return ResponseEntity.ok(processor.process(response.getParams()));
            }

            return ResponseEntity.noContent().build();
        } catch (Exception e) {
            log.error("> status: Packet ERROR, msg: {}", e.getMessage());
            
			// 3. 응답값 생성 - Exception 발생시
            // 소켓 통신 실패
            return ResponseEntity
                    .internalServerError()
                    .body("소켓 연결 정보가 잘못되었습니다. 관리자에게 문의해주세요.");
        }
    }
}

메소드 호출은 일반 모듈과는 다를바 없지만, [4]에서 응답 값 본문을 Callback으로 위임하여 처리합니다.

Test

아래는 지문 템플릿을 요청하는 테스트 코드입니다.

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

public class PacketTest {
    private final PacketConnProps props;

    PacketTest(){
        this.props = PacketConnProps.builder()
                .ip("127.0.0.1")
                .port(8080)
                .timeout(1000)
                .build();
    }

    @Test
    @DisplayName("지문 템플릿 요청")
    public void fpTest(){
        ResponseEntity<?> result = PacketUtil.connect(props, Command.FINGERPRINT, params -> {
            // 응답 처리
            return params;
        });

        Assertions.assertEquals(HttpStatus.OK, result.getStatusCode());
    }
    
    @Test
    @DisplayName("지정맥 템플릿 요청")
    public void veinTest(){
        ResponseEntity<?> result = PacketUtil.connect(props, Command.FINGERVEIN, params -> {
            // 응답 처리
            return params;
        });

        Assertions.assertEquals(HttpStatus.OK, result.getStatusCode());
    }
}

보일러 플레이트 코드가 모두 제거되어 미들웨어에 기능이 추가되더라도 깔끔하고 직관적인 코드를 작성할 수 있게 되었습니다.


Sub) 통신 단위 정의

통신 단위를 Java Object로 정의합니다.

// 패킷
@Builder
@Getter
@Setter
public class Packet {
    @NonNull
    private BioCommand command;

    @Builder.Default
    private BioCommandType type = CommandType.REQUEST;
    private String description;

    @Builder.Default
    private Map<String, Object> params = new HashMap<>();
}
// 요청 유형
@RequiredArgsConstructor
public enum BioCommandType {
    REQUEST("REQ"),
    RESPONSE("RES"),
    ERROR("ERR"),
    ;
    
    private final String code;
}
// 요청 명령어
@RequiredArgsConstructor
public enum BioCommand {
    FINGERPRINT("fp", "지문"),
    FINGERVEIN("vein", "지정맥"),
    FACE("face", "얼굴"),
    ;
    
    private final String code;
    private final String name;
}
// 소켓 연결 정보
@Builder
@Getter
@ConfigurationProperties("{properties 이름}")
public class ConnProps {
    private String ip;
    private Integer port;
    private Integer timeout;
}

Map을 즐겨 사용하던 저는 VO, DTO 방식에 빠져 통신 단위를 정의하기 시작했고, Map을 사용했을 때보다 훨씬 더 직관적이고 유연하다는 것을 깨닫게 되었습니다.

DTO, VO

DTO (Data Transfer Object): 계층 간 데이터 교환을 위해 사용하는 객체
VO (Value Object): DTO와 비슷하나, 내부 속성 값을 변경할 수 없는(immutable), Read-Only의 의미적 특성을 가진 객체


💎 References


💎 마치며

애플리케이션 개발자로서, 코드의 절반 이상은 복사ㆍ붙여넣기 이기 때문에 애플리케이션 자체를 개발하는 것은 어렵지 않다고 생각합니다.

하지만, 아름다운 코드를 작성하는 것은 굉장히 어렵습니다. 사실 아름답다는 것은 주관적이기 때문에 정의를 내리기도 어렵습니다. 그럼에도 우리가 디자인 패턴을 공부하는 이유는 통상적으로 느끼는 아름다움이 있기 때문이겠지요.

제가 생각하는 아름다운 코드는 일관적인, 가독성이 좋은, 예측 가능한, 좋은 구조를 가진 등이 있지만 가장 중요한 것은 계속 발전하는 코드입니다. 아름답기 위해 끊임없이 공부하고 적용하면서 코드를 발전시켜나갈 것입니다.

profile
Java, Spring 기반 풀스택 개발자의 개발 블로그입니다.

0개의 댓글