저는 여의도 소재의 회사를 다니게 되면서 금융 차세대 프로젝트에 참여하게 되었습니다. 주요 업무로는, 해외 증권 거래소의 청산(Clearing) 시스템의 일부 파트의 레거시 코드에서 Java로 마이그레이션 하는 업무를 담당하게 되었는데요. 처음에는 익숙하지 않은 (학교에서 배웠지만 안쓰다보니 까먹은) C 기반의 레거시 코드나 낯선 도메인에 빨리 적응하는 것이 중요하다고 생각했습니다. 그러나, 제가 개발해야 하는 시스템이 일반 웹 서비스와는 다른 통신 규약을 가지고 있다는 점을 알게되었고, 마이그레이션 과정에서, 규약에 맞는 코드를 설계하는 것이 가장 먼저 수행해야 할 핵심 과제였습니다.
우선 저희 회사에서 개발하고 있는 시스템의 통신 규약을 간단히 설명드리겠습니다. 일반적인 금융권 레거시 시스템에서 자주 사용되는 것으로 알고 있어서, 회사마다 조금씩은 다르겠지만 관련 업무 경험이 있으신 분들이라면 익숙하실 것 같습니다.
저희 서비스는 웹 서비스이긴하나, 앞단의 웹 서버에서 HTTPS 요청을 받은 후, 제가 현재 개발하고 있는 AP 서버와 TCP 통신을 통해 데이터를 주고 받습니다. 일반적인 WAS에서 수행하는 비즈니스 로직 수행, DB 조회 등은 AP서버에서 진행한다고 보시면됩니다. 즉, AP서버에서는 HTTP 요청을 직접 받을 수 없는 시스템 구조입니다.
또한, 전문통신(Fixed-Length) 규약을 사용하고 있습니다. JSON과 같은 데이터 형식이 아니라, 약속된 위치와 길이에 데이터를 위치시키는 형식입니다. 아래와 같이 하나의 문자열 Text로 요청이 들어오며, 내부적인 규약에 의해 각 데이터 필드값의 길이를 정의합니다. 아래 두 예시 데이터는 같은 DTO 객체로 변환됩니다.
[전문 통신]
20251010 Minjun
[JSON]
{
"date": "20251010",
"name": "minjun"
}
[DTO]
public class FixedExampleDTO {
private final String name;
private final String date;
// 생성자, getter 등
}
REST + JSON 환경에서 개발을 해왔던 저로써는 아래와 같은 통신 규약과 구조가 낯선 환경이었습니다. 앞에서의 설명에서 보실 수 있듯이, Spring Web, JSON 변환을 위한 Jackson은 당연히 사용하지 못하는 상황이었습니다.
기존 시스템의 문제점
문제점을 설명드리기에 앞서, 요청이 실제 서비스로 어떻게 매핑되는지 설명드리겠습니다. 각 요청에는 수행할 함수명이 전문 통신의 문자열 형태로 포함되어 있으며, 애플리케이션은 이 함수명을 기반으로 해당 서비스를 찾아 실행하는 구조입니다. 예를 들면, 요청 메시지에 "serviceA"라는 문자열이 포함되어 있으면, 서버에서는 함수명이 "serviceA"인 서비스 함수가 수행되어야합니다. 앞으로, 통일된 용어 설명을 위해 "문자열 함수명"은 ServiceCode라는 용어를 사용하겠습니다.
[전문 요청]
"...demoService...Minjun20251010"
[수행되어야하는 실제 서비스 코드]
public void demoService {
//service code
}
뒤에서 후술하겠지만, Java 마이그레이션을 할 경우 수행되어야하는 함수를 ServiceCode기반으로 런타임에 동적으로 함수를 호출해야한다는 틀은 크게 바뀌진 않습니다. Spring MVC와 같은 프레임워크의 경우에도 이러한 원리를 사용하기도 하죠. 그러나 이를 구현한 레거시 코드에는 몇가지 문제점이 있었습니다.
1) 런타임 함수 조회의 비효율성
레거시 코드의 흐름상으로는, 요청이 들어올 때마다 ServiceCode에 대응하는 함수나 메서드를 런타임에서 탐색했습니다.
- dlopen() → 라이브러리 로드 → dlsym() → 함수 포인터 조회
간단하게 이 과정을 설명하면, ServiceCode를 포함하는 .so파일명은 하드코딩 기반의 배열로 관리하고 있었고, 이를 이용하여 .so 라이브러리 주소를 조회한 후 ServiceCode에 해당하는 함수 포인터를 가져오는 과정입니다. 매번 심볼 테이블을 조회하여 함수명에 해당하는 함수포인터를 조회하는 연산이 내부적으로 수행되고 있었습니다.
SERVICE_INFO SERVICES[] = { {"demoService" , "demoService.so"} }
문제는 함수 수행과 관련된 정보가 컴파일 시점에 이미 결정되어 있다는 점입니다.
- 서비스 함수 이름, DTO 타입, ServiceCode 등은 런타임 동안 변경되지 않음
- 그럼에도 매 요청마다 함수와 DTO를 찾아오는 작업은 불필요한 연산
- ServiceCode -> .so파일을 관리하기 위한 수동 매핑 테이블이 꼭 필요함 (하드코딩 기반)
Java의 리플렉션 함수(getDeclaredMethod)을 사용하더라도 DTO를 포함한 Parameter의 Class Type도 알아야했으므로, 기존 구조대로 구현하고 싶어도 할 수 없는 상황이었습니다. 클래스 내부의 함수 전체조회(getDeclaredMethods)하여 ServiceCode와 함수명이 일치하는 Method를 찾아야하는데, 순회작업과 매번 Method 객체를 생성해야한다는 것 모두 불필요하다 생각했습니다.
이를 해결하기 위해, 각 서비스 함수의 Reflection 호출을 위해 필요한 DTO, 서비스 빈 정보 등을 미리 조회하여 캐싱해두는 구조로 변경하였습니다.
2) DTO와 요청 데이터간 변환작업의 비효율성
기존 레거시 코드에선 서비스 함수 내에서 주어진 서비스 로직만 수행되는 것이 아니라, 전문 요청, 응답 데이터와 DTO 변환을 수행하는 코드들도 포함되어 있었습니다. 이로 인해 각 서비스와 직접적인 관련이 없는 유틸성 작업이 곳곳에 섞여 있었고, DTO 변환을 위한 반복적인 코드가 다수 존재하는 문제가 있었습니다. 또한, 단일 함수에서 서비스 함수 매핑(동적 라이브러리 호출), 공통 VO 변환, 서비스 호출 등 다양한 작업이 모두 수행되다 보니, 시스템이 따르는 규약과 매핑 구조, 흐름을 직관적으로 이해하기 힘들었습니다.
[DemoService.pc 내부]
#define NAME_LEN 10
#define DATE_LEN 10
typedef struct {
char name[NAME_LEN];
char date[DATE_LEN];
int age;
} RequestDTO;
void mapRequest(const RawRequestData* raw, RequestDTO* dto) {
memcpy(dto->name, raw->rawName, sizeof(raw->rawName));
memcpy(dto->date, raw->rawDate, sizeof(raw->rawDate));
//필드가 많아지면 더 많은 수동 매핑 코드 추가
}
이를 해결하기 위해, 전문통신용 MVC 모델을 설계, 구현하여 핸들러 호출 이전 이후 계층에 대한 경계와 책임을 명확히 분리하고, 현재 시스템 규약을 한눈에 알아볼 수 있도록 개선하기로 결정하였습니다.
주요 구성 요소
1. HandlerMapper
우선 Handler Mapper라는 별도의 클래스를 구현하여 서비스간 매핑을 담당하게 구현하였습니다. 매핑에 필요한 코드는 다음과 같습니다.
1) Handler임을 구분해주는 요소
각 서비스의 진입 지점을 Handler로 분리하고, @Controller 어노테이션을 추가하였습니다. HandlerMapper의 스프링 빈 등록시 @Controller 어노테이션이 포함된 Bean을 모두 스캔하여, 추후 함수 호출에 필요한 정보를 객체에 담아 어플리케이션에 미리 캐싱해두도록 구현하였습니다.
2) 함수 매핑 & 수행에 필요한 정보
각 Handler의 ServiceCode을 Key로, 서비스 함수 정보를 별도의 VO에 담아 Value로 구성하였고, 해당 Key - Value쌍을 HashMap에 저장하였습니다. 해당 Map은 읽기 전용이며, 런타임에 수정이 필요하지 않으며 추가적인 정보를 가져오지도 않습니다. 요청시 포함된 함수열 문자열을 파싱한 후 HandlerVO를 조회하여 서비스를 수행하면 됩니다.
public record HandlerVO(
Object bean, // 서비스 인스턴스
Method method, // 호출할 서비스 메서드
Class<?> requestDtoType, // 요청 DTO 타입
Class<?> responseDtoType // 응답 DTO 타입
) {}
@Component
@RequiredArgsConstructor
public class HandlerResolver {
private final ApplicationContext applicationContext;
private final Map<String, HandlerVO> handlerMethodMap = new HashMap<>();
@PostConstruct
public void init() {
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Controller.class);
for (Object bean : beans.values()) {
//bean에 포함된 method 조회
Method[] methods = bean.getClass().getMethods();
//DTO ClassType 조회, 저장
for (Method method : methods) {
Class<?> requestDTOType = getRequestParameterType(method);
Class<?> responseDTOType = getResponseParameterType(method);
handlerMethodMap.put(
method.getName(),
new HandlerVO(bean, method, requestDTOType, responseDTOType)
);
}
}
}
}
2. BeanIOUtil
전문 요청·응답 데이터와 Java DTO 간의 변환을 수행하는 코드가 필요했습니다.
C 언어에서는 memcpy를 이용해 각 DTO 필드를 수동으로 변환할 수 있었지만, Java에서는 memcpy를 사용할 수 없으며, 기존 방식대로 매핑할 경우 DTO 클래스의 각 필드를 수동으로 변환 코드를 작성해야 했습니다. 또한, ServiceCode에 해당하는 DTO 타입을 자동으로 조회할 수 없어서, 각 서비스에서 DTO 클래스를 명시적으로 선언하고 객체를 생성해야 했습니다.
결과적으로 다음 두 가지 공통 작업이 필요했습니다.
- 단일 DTO 개별 필드의 변환 작업 공통화 (고정길이만큼 파싱, 공백 제거, 타입 변환 등)
- 각 서비스에 해당되는 요청·응답 DTO 타입의 매핑 및 조회 공통화
결론부터 말씀드리면, beanio 라이브러리를 사용하기로 결정했습니다.
https://github.com/beanio/beanio
GitHub - beanio/beanio: BeanIO 3, a Java library for marshalling and unmarshalling bean objects from XML, CSV, delimited and fix
BeanIO 3, a Java library for marshalling and unmarshalling bean objects from XML, CSV, delimited and fixed length stream formats. - GitHub - beanio/beanio: BeanIO 3, a Java library for marshalling...
github.com
필드를 final로 지정할 수 없고, Java Record를 지원하지 않아 DTO를 불변 객체로 사용할 수 없다는 아쉬움이 있긴하나 직접 구현하는 것 보다는 훨씬 더 다양한 타입 변환을 안정적으로 지원(inner class, 컬렉션 자료구조)하는 것이 큰 장점이라 판단하였습니다. Annotation 기반으로 고정길이를 지정할 수 있어서 필드명 - 실제 필드간 매핑도 컴파일 타임에서 쉽게 수행할 수 있습니다.
@Record
public class FixedExampleDTO {
//final 불가능
@Field(length = 10)
private String name;
@Field(length = 10)
private final String date;
//getter, 기본 생성자 (필수) 등
}
(XML을 작성하여 매핑하는 방법이 있긴하나, 필드 변수명을 XML로 일일히 옮기는 작업이 생각보다 많이 불편합니다. 물론 Annotation 방법의 경우에도 서비스 코드인 DTO에 어노테이션이 붙게 되는 단점이 있긴하나, 이러한 작업이 없기 때문에 이를 활용하기로 선택하였습니다. 자세한 실행 방법은 공식 문서(https://beanio.github.io/docs/reference-guide)를 활용해주세요.)
3. Dispatcher
저희 서비스의 요청, 응답 구조는 크게 Header(공통 VO), Body(개별 서비스 DTO)로 구성되어있기 때문에 Header의 총 고정길이만큼 파싱하여 두 부분을 나눈후, 각 Header와 Body를 beanio를 활용해 Java DTO로 변환합니다. Body의 경우 각 서비스의 DTO class Type을 ServiceCode에 해당하는 HandlerVO에서 참조하여 객체를 생성합니다.
public String dispatch(String rawRequest) {
try {
// 1. 헤더 파싱
String rawHeaderRequest = rawRequest.substring(0, HEADER_LENGTH);
Header header = beanIOUtil.marshall(rawHeaderRequest, Header.class);
// 2. HandlerVO 조회 (캐싱된 구조 활용)
HandlerVO handlerVO = HandlerMapper.resolve(header.tradeCode);
// 3. 바디 파싱
String rawBodyRequest = rawRequest.substring(HEADER_LENGTH);
Object requestDTO = beanIOUtil.marshall(rawBodyRequest, handlerVO.requestDtoType());
// 4. 메서드 호출
String rawResponse = handlerVO.method().invoke(handlerVO.bean(), requestDTO);
return beanIOUtil.unmarshall(rawResponse, handerVO.responseDTOType());
//Reflection 함수 호출 예외 전파 방지 (Custom Exception 변환)
} catch (IllegalAccessException e) {
//생략
} catch (InvocationTargetException e) {
//생략
} catch (Exception e) {
//생략
}
}
DTO 변환, 핸들러 매핑 등이 어떻게 진행되는지에 대한 구현 코드는 각 컴포넌트에서 담당하게 분리하여 모듈화를 진행하였습니다. 이를 하나의 dispatch 함수에서 각 컴포넌트를 호출하여, Handler 호출전에 어떠한 규약으로 데이터가 변환되고 서비스가 수행되는지 한눈에 알 수 있게 구현할 수 있었습니다.
결론
실제로 Spring Web 라이브러리를 직접 사용할 수 없는 상황에서 요청, 응답을 처리하는 인터페이스 파트를 직접 구현하는 것 자체에 대한 부담이 있었습니다. 해당 파트가 제대로 수행되지 못하면 추후 진행될 서비스 코드 Java 마이그레이션의 경우에도 안정적인 상태로 진행하지 못한다고 판단을 하였기 때문입니다.
구현하는 과정에서 Spring MVC의 동작 원리를 공부하는 작업을 병행하여 각 모듈의 역할과 전체적인 구현 흐름을 이해하고, 레퍼런스로 삼았습니다. REST API와 같이 다양한 내부 통신 규약을 모두 구현할 필요가 없었고, 이와 비교했을때에는 상대적으로 매우 간단한 통신 규약이었기 때문에 직접 개발을 할 수 있었던 것 같습니다.
객관적으로 보면 전문통신은 레거시 통신 규약의 잔여물이고 이로 인한 단점이 많다고 알고 있습니다. 하지만, 어쩔 수 없이 이러한 통신 규약을 바꾸진 못하는 상황에서 유연하고 비효율적인 코드를 최소화할 수 있는 구조를 가져가기 위해 노력한 것 같습니다. 하루 빨리 모두가 HTTPS, JSON으로 데이터를 주고 받을 날을 꿈꾸며 이번 포스팅은 마치도록 하겠습니다. 감사합니다.
