2025. 7. 1. 19:02ㆍFramework/NestJS
이번 블로그에서는 상반기 동안 진행한 WMS 프로젝트에서 마주했던 문제들과 그 해결 방법을 공유하고자 합니다. 제가 정의하고 해결한 문제는 총 4가지인데, 4가지의 특징을 한데 모아보니 제가 공통적으로 고민했던 것은 "어떻게 하면 도메인을 지키는 설계를 할 수 있을까?" 였습니다. 여기서 말하는 도메인이란 비즈니스 로직을 의미하며 외부 인프라로 부터 독립된 소프트웨어가 해결해야 할 특정 비즈니스 문제나 상황을 의미합니다. 저는 이번 블로그에서 제가 도메인을 지키기 위해 어떤 고민과 노력을 했는지 공유 해보려고 합니다.
제가 정의한 문제는 총 4개로
- 동기적으로 수행되어야 하는 외부 서비스 호출을 처리하는 방법
- 비동기적으로 수행가능한 외부 서비스 호출을 처리하는 방법
- 다중 서비스 클래스를 사용하는 경우 트랜잭션을 관리하는 방법
- 비동기 처리 구현 방식의 추상화 필요성
1. 동기적으로 수행되어야 하는 외부 서비스 호출을 처리하는 방법

WMS에서는 다음과 같은 세 가지 작업이 하나의 트랜잭션 내에서 수행되어야 한다는 비즈니스 요구사항이 있었습니다.
- 주문의 상태 검증(외부 서비스 API 호출)
- 출고지시서 생성
- 피킹 지시서 생성
이 중 주문 상태를 검증하기 위해 셀메이트 측에 주문이 취소되었는지를 확인하는 HTTP 요청이 필요했습니다. 기존에는 데이터베이스 트랜잭션이 열린 상태에서 외부 서비스 호출을 시작함으로써, 불필요하게 DB 리소스를 점유하고 동시성 저하를 초래하는 문제가 있었습니다. 또한 일부 로직에서는 비즈니스 처리 중간에 주문 상태를 검증하며, 서비스 내부 메서드가 외부 응답에 과도하게 의존하는 문제가 발생하기도 했습니다.

이번 사례에서는 셀메이트로부터 단순 조회 형태의 외부 서비스 호출만 있었지만, 만약 외부 서비스에서 데이터를 조작하는 호출이 포함된다면 상황은 더욱 복잡해집니다. 예를 들어, 피킹 지시서 생성 과정에서 오류가 발생할 경우, 출고 지시서를 포함한 모든 DB 작업뿐만 아니라 셀메이트에 전달된 데이터 변경 사항도 함께 롤백되어야 데이터 정합성이 보장됩니다. 그러나 외부 서비스는 로컬 DB 트랜잭션과 달리 롤백이 불가능하기 때문에, 처리 실패 시 서비스 간 데이터 불일치라는 심각한 문제가 발생할 수 있습니다.

따라서 외부 서비스 호출이 반드시 동기적으로 수행되어야 하는 경우, 트랜잭션을 시작하기 전에 외부 호출을 먼저 처리하는 것이 바람직합니다. 이렇게 설계할 경우, 외부 서비스 호출이 실패했을 때 굳이 트랜잭션 내 비즈니스 로직의 롤백을 고려할 필요가 없어집니다. 또한 외부 호출과 트랜잭션의 경계를 명확히 분리할 수 있어 디버깅이 쉬워지고, 데이터베이스 트랜잭션의 락 타임(lock time)을 줄여 시스템 성능 향상에도 기여할 수 있습니다.
2. 비동기적으로 수행가능한 외부 서비스 호출을 처리하는 방법
셀메이트로부터 주문 상태를 확인한 후, 취소 상태가 아닌 경우에만 출고 지시서와 피킹 지시서를 생성해야 한다는 비즈니스 요구사항이 있었습니다.
이처럼 주문 상태 검증이 반드시 이후 작업에 선행되어야 하는 흐름은, 본질적으로 동기적인 처리 순서를 요구합니다.
따라서 앞서 말씀드린 것처럼, 이런 요구사항이 있을 경우에는 트랜잭션을 시작하기 전에 외부 서비스를 호출하는 방식이 유지보수 측면에서 훨씬 유리합니다.
만약 WMS에서 DB 트랜잭션 작업이 성공적으로 완료된 후, 셀메이트로 데이터 조작 요청을 보내야 하는 상황이라면, 이를 어떻게 처리하는 것이 가장 유리할까요?

출고 물류 창고에서는 송장 채번이 다음 작업보다 반드시 먼저 완료되어야 할 필요는 없었습니다.
즉, 피킹 지시서까지 생성된 후 사용자에게 성공 응답을 주는 방식이, 송장 채번까지 완료된 후에 응답을 주는 방식보다 사용성이 더 높다고 판단했습니다. 왜냐하면, 송장 채번이 실패하더라도 피킹 작업 자체는 진행할 수 있기 때문입니다.
출고 지시서 생성, 피킹 지시서 생성, 그리고 송장 채번을 위한 택배사 연동이 하나의 DB 트랜잭션에 포함되어 있다고 가정해보겠습니다.
이 경우, 송장 채번을 위한 외부 서비스 연동이 완료될 때까지 트랜잭션이 계속 유지되므로, 트랜잭션 락 타임이 길어지고 결과적으로 전체 애플리케이션 성능을 저하시킬 수 있습니다. 또한 송장 채번에 실패할 경우, 사실상 롤백할 필요가 없는 출고 지시서와 피킹 지시서까지 함께 롤백되면서 불필요한 비용 낭비가 발생할 수 있습니다.

따라서 송장 채번처럼 비동기로 수행 가능한 작업은 트랜잭션이 종료된 이후에 호출하는 것이 안전합니다.
이렇게 하면 트랜잭션의 락 타임을 줄일 수 있고, 외부 서비스 연동 실패가 도메인 로직 전체의 실패로 전이되는 것을 방지할 수 있어, 보다 안전한 코드를 설계할 수 있습니다. 또한 나중에 실패한 송장 연동 내역을 확인하고 재연동하는 기능이 추가될 경우, 관련 관심사를 별도의 모듈로 분리하기도 쉬워져, 유지보수 비용도 크게 절감할 수 있습니다.
3. 다중 서비스 클래스 호출시 트랜잭션 관리 전략
주문 상태 검증, 출고 지시, 피킹 지시, 송장 채번은 각각 서로 다른 관심사를 가진 기능이기 때문에, 별도의 서비스 모듈로 분리하여 구현하도록 설계되어 있었습니다. 이는 객체지향의 **단일 책임 원칙(Single Responsibility Principle)**을 잘 준수한, 깔끔하고 유지보수하기 용이한 코드 구조라고 생각합니다.

다만 문제는, 출고 지시와 피킹 지시처럼 각각의 작업 결과가 서로에게 영향을 미치는 경우였습니다. 즉, 둘 중 하나라도 실패하면 전체 작업이 실패로 처리되어야 하는 상황이 발생했습니다.

이 경우, 예시처럼 Shipping Service(출고 지시)와 Picking Service(피킹 지시)가 각각 별도의 트랜잭션을 관리한다면, 피킹 지시의 실패가 출고 지시의 성공을 롤백할 수 없습니다. 단일 책임 원칙을 지키면서도 두 작업을 하나의 트랜잭션으로 묶어야 한다면, 기존 방식과는 다른 새로운 전략이 필요하다고 생각했습니다.
그래서 제가 아이디어를 얻은 설계 전략은 DDD에서 제시하는 Application Service와 Domain Service의 분리입니다.

DDD는 Domain Driven Design의 약자로, 도메인 주도 설계를 의미합니다. 복잡한 소프트웨어를 실제 비즈니스 도메인을 중심으로 설계하고 개발하는 방법론입니다. DDD에서 Application Service와 Domain Service를 분리하는 전략은,
각 역할을 명확히 구분하고 도메인 모델의 순수성을 유지하기 위한 설계 원칙에 기반합니다. 시스템의 도메인 지식은 입력, 출력, 트랜잭션 같은 다른 관심사와 독립적으로 다뤄져야 한다는 DDD의 철학은, 제가 직면한 문제를 완벽하게 해결할 수 있었습니다.
각 서비스의 책임을 설명드리면,
먼저 Application Service는 Use Case를 받아 작업 흐름을 조율하고 결과를 반환하는 중개자 역할을 합니다.
Use Case별로 프로세스를 조율하는 것에만 집중하며, 비즈니스 규칙, 즉 도메인 로직은 직접 구현하지 않는 서비스입니다.
반면, Domain Service는 특정 도메인 비즈니스 규칙을 실제로 구현하는 클래스입니다.
순수한 비즈니스 규칙만을 담당하기 때문에, 각 도메인 규칙을 명확히 구현할 수 있고,
이로 인해 서비스 간 응집도는 높아지고 결합도는 낮아져, 전체 애플리케이션의 유지보수성이 크게 향상됩니다.

DDD 설계 전략을 적용해 새롭게 도입한 트랜잭션 관리 전략은 다음과 같습니다.
- Application Service에서 트랜잭션을 선언하고,
- 각 도메인 서비스에는 트랜잭션 단위 작업을 수행하는 QueryRunner 인스턴스를 주입합니다.
도메인 서비스는 주입받은 QueryRunner를 통해 엔티티 생성과 변경을 제어하고 결과를 반환함으로써, 도메인 조작을 외부로부터 안전하게 보호할 수 있습니다.
Application Service는 작업 실패 시 롤백 처리와 트랜잭션 종료를 담당하며, 출고 지시 생성 이후 피킹 지시 생성 등 프로세스 흐름도 관리합니다.
이 전략의 주요 장점은 세 가지입니다.
- 역할 분리에 따른 유지보수 용이성
Application Service는 Use Case 조율, Domain Service는 비즈니스 규칙 구현에 집중하여 각 계층 책임이 명확해지고, 코드 가독성과 유지보수성이 크게 향상됩니다. - 도메인 로직 재사용성과 테스트 용이성
Domain Service가 순수 비즈니스 로직만 포함하기 때문에 단위 테스트가 쉽고, 여러 Application Service에서 동일한 도메인 로직을 재사용할 수 있습니다. - 외부 의존성과 도메인 로직의 분리
외부 시스템 연동과 트랜잭션 관리는 오직 Application Service에서만 수행하여, 도메인 모델을 외부 변화로부터 안전하게 보호할 수 있습니다.
4. 비동기 처리 구현 방식의 추상화 필요성
비동기 처리뿐만 아니라, 모든 구현에서 내부 구현을 외부에 노출하는 것은 유지보수성을 저해한다고 생각합니다.
객체지향에서 단일 책임 원칙만큼 중요한 것이 바로 캡슐화이며, 저 역시 비동기 처리 방식 또한 반드시 캡슐화되어야 한다고 느꼈습니다.
WMS 프로젝트에서는 피킹된 재고의 검수가 출고 오더 건에 대한 마지막 단계로, 출고 처리를 진행합니다.
출고 처리가 완료된 재고는 WMS에서 프로세스가 완료된 상태로 간주합니다. 이후 WMS는 셀메이트에 해당 주문의 출고 완료를 알리는 실적 전송 단계를 수행합니다.

해당 처리 로직은 당연히 트랜잭션이 종료된 이후에 수행되었습니다. 그러나 현재 구현은 HTTP 통신을 사용하고 있어 몇 가지 한계가 존재합니다. 예를 들어, HTTP 방식은 동기적 요청-응답 구조로 인해 외부 서비스의 응답 지연이나 실패가 전체 프로세스에 직접적인 영향을 줄 수 있습니다. 추후에는 비동기 처리 요구사항에 따라, HTTP 통신 대신 RabbitMQ와 같은 메시지 큐를 사용하는 방식으로 전환하는 계획이 확정된 상태입니다.
따라서 검수 및 출고를 담당하는 Application Service에서는 실적 반영 호출이 내부 구현 방식에 의존하지 않도록 분리할 필요가 있었습니다. 그 이유는, 서비스 계층이 특정 구현 방식에 의존하면, 구현 방식 변경 시 서비스 로직까지 수정해야 하고, 비즈니스 로직과 전달 방식이 뒤섞여 유지보수가 어려워지며 단위 테스트 역시 복잡해지기 때문입니다.
Application Service는 비즈니스 로직의 처리 흐름, 즉 "무엇을 할 것인가"만 인지하고, "어떻게 처리되는가"는 알지 않는 것이 유지보수를 용이하게 한다고 판단했습니다.

이 문제를 해결하기 위한 아이디어는 헥사고날 아키텍처에서 얻었습니다. 헥사고날 아키텍처란 도메인 로직을 중심에 두고, 외부 시스템과의 상호작용을 **포트(Port)**와 **어댑터(Adapter)**를 통해 분리하여 설계하는 아키텍처입니다.
- 포트(Port): 도메인 입출력 인터페이스
- 어댑터(Adapter): 실제 구현체
즉, 도메인 로직을 외부 환경과 철저히 분리하고, 입출력은 포트와 어댑터를 통해 연결함으로써, 도메인 모델의 독립성과 유연성을 극대화하는 설계 방법론입니다.
헥사고날 아키텍처의 핵심 철학은, 비즈니스 로직이 외부 환경에 독립적이어야 한다는 점입니다. 즉, 입력과 출력 방식이 달라지더라도 도메인 로직 자체는 변경되지 않아야 하며, 의존성은 항상 외부에서 내부로 흐르도록 설계해야 합니다.
헥사고날 아키텍처가 비동기 처리 추상화에 적합한 이유는, 애플리케이션의 핵심 규칙이 외부 세계의 변화에 영향을 받아서는 안 된다는 철학에 잘 부합하기 때문입니다. 비동기 전송은 본질적으로 기술 선택의 문제이지, 비즈니스 규칙이 아니므로 도메인 로직이 구현 방식을 알거나 의존하는 것은 위험합니다.
위 예시에서 언급한 실적 전송 로직의 비동기 처리 구현을 추상화한 사례를 보면, 먼저 **포트(Port)**를 정의하는데, 포트는 도메인 로직과 입출력을 연결하는 인터페이스 역할을 합니다.

다음으로 Application Service를 작성합니다. 이 서비스는 Domain Service와 앞서 정의한 포트 인터페이스에 대한 의존성을 주입받아 사용합니다.

다음은 실제 비동기 처리 구현을 담당하는 어댑터 클래스 입니다. 이번 예시에서는 RabbitMQ를 활용한 비동기 처리 구현 클래스와, HTTP를 사용한 비동기 처리 구현 클래스, 두 가지를 가정하고 있습니다.


두 어댑터 클래스는 구현방식을 내부로 캡슐화 하면서 ShipmentApplicationService에서는 어느 구현 클래스의 의존성을 주입받더라도 비즈니스 로직을 인프라 의존성으로 부터 완벽히 방어할 수 있습니다.

만약 비동기 처리 구현 방식을 HTTP에서 RabbitMQ로 변경해야 할 경우, Provider에서 RabbitMQ 기반 구현 클래스를 주입하도록 설정만 바꾸면 됩니다. 이렇게 하면 내부 비즈니스 로직을 수정할 필요 없이 손쉽게 구현 방식을 변경할 수 있습니다.
비즈니스 로직이 외부 인프라 기술에 의존하지 않도록 설계함으로써, 비즈니스 규칙을 안전하게 보호할 수 있고, 외부 시스템 연동 방식이 변경되더라도 어댑터만 교체하면 쉽게 대응할 수 있습니다.뿐만 아니라, 도메인이 순수하게 설계되어 단위 테스트도 용이한 코드로 리팩토링되는 효과도 있습니다.
헥사고날 아키텍처는 본 사례처럼 비동기 처리 구현의 추상화에 매우 적합한 아키텍처입니다.
무조건적으로 좋은 아키텍처라기보다는, 이 방법으로 문제를 효과적으로 해결할 수 있음을 말씀드리고 싶었습니다.
결론
이번 글에서는 도메인을 지키기 위해서 4가지의 문제정의와 해결 전략에 대해 공유 했습니다. 다만 저는 문제를 해결하는데서 그치지 않고 팀내에서 문제 해결에 대한 내용을 공유 하는 것이 장기적으로 어플리케이션의 유지보수성을 높일 수 있다고 판단했습니다. 추후에 사내 개발자 컨퍼런스에서 해당 주제를 들고 발표할 기회가 생겼고 추후에 그 후기에 대해 블로그 글을 작성하려고 합니다.
마지막으로 문제해결과 공유와 관련된 선배 소프트웨어 개발자들의 격언과 세계적인 IT 기업의 개발 조직내의 철학에 대해 잠깐 공유 드리고자 합니다.
프로젝트를 진행하면서 마주하는 문제들은 단순한 장애물이 아니라 성장과 혁신의 기회입니다. 하지만 문제를 정확히 정의하지 않으면 올바른 해결책을 찾기 어렵고, 문제를 제대로 공유하지 않으면 팀 전체의 역량을 최대한 활용할 수 없습니다.
토마스 쿠퍼(Thomas S. Kuhn)는 그의 저서 『과학 혁명의 구조(The Structure of Scientific Revolutions)』에서 이렇게 말했습니다.
“문제를 제대로 정의하는 것이야말로 혁신적인 해결책을 찾는 첫걸음이다.”
이는 소프트웨어 개발에서도 마찬가지입니다. 문제를 명확하게 정의하고, 그에 따른 해결 방법을 팀과 공유하는 과정은 프로젝트의 성공을 좌우합니다. 팀원 모두가 문제의 본질과 그 해결 방향을 이해할 때, 각자의 역할에서 최적의 기여를 할 수 있기 때문입니다.
또한 『클린 아키텍처(Clean Architecture)』의 저자 로버트 C. 마틴(Robert C. Martin)은 이렇게 강조합니다.
“좋은 설계란 문제를 해결하는 것뿐만 아니라, 문제를 명확히 인식하고 공유하는 것이다.”
우리 팀이 직면한 문제와 이를 해결하기 위해 고민한 방법들을 투명하게 공유한다면, 이는 단순히 한 사람의 성과를 넘어서 팀 전체의 성장과 조직 문화 발전으로 이어질 것입니다.
결론적으로, 문제 정의와 해결책 공유는 단순한 업무 보고가 아니라, 팀의 지식과 경험을 축적하고, 더 나은 미래를 설계하는 가장 강력한 도구입니다.
앞으로도 우리는 문제를 숨기거나 회피하지 않고, 함께 고민하며 끊임없이 발전해 나가야 할 것입니다.
세계적으로 성공한 IT 기업들은 문제 정의와 해결책 공유를 팀 문화의 핵심 가치로 삼고 있습니다.
예를 들어, 구글(Google)은 ‘심층적 토론과 문제 공유’를 중시하는 문화로 유명합니다.
구글은 정기적인 ‘팀 회고(RETRO)’와 ‘문제 해결 워크숍’을 통해 모든 팀원이 프로젝트에서 겪는 문제를 공유하고, 함께 최적의 해결책을 모색합니다. 이는 구성원들의 다양한 시각을 반영해 보다 창의적이고 견고한 솔루션을 만들어내는 데 크게 기여합니다.
또한, 아마존(Amazon)은 ‘데이터 중심 문제 정의’를 통해 문제를 명확히 분석하고 공유하는 데 집중합니다.
아마존의 ‘프레임워크 메커니즘’과 ‘리더십 원칙’ 중 하나인 ‘Dive Deep’은 팀원들이 문제의 근본 원인을 깊이 파고들어 팀과 투명하게 공유하도록 장려합니다. 이런 문화 덕분에 문제 해결의 효율성과 정확성이 매우 높아집니다.
넷플릭스(Netflix)는 ‘자율과 책임’이라는 원칙 아래, 각 팀원이 문제를 스스로 정의하고 해결책을 주도적으로 공유하는 문화를 갖추고 있습니다.
넷플릭스는 ‘피드백 문화’를 통해 끊임없이 서로의 문제 해결 방식을 공유하며 성장합니다. 이 과정에서 팀원 개개인의 전문성과 창의성이 극대화됩니다.
이처럼 세계적인 IT 기업들은 문제를 팀원들과 적극적으로 공유하고, 문제 정의부터 해결책 마련까지 협업하는 문화를 통해 높은 생산성과 혁신을 이뤄내고 있습니다.
우리 팀도 이와 같은 방향으로 문제를 명확히 정의하고, 열린 커뮤니케이션을 통해 함께 해결하는 문화를 만들어 나간다면 더욱 강한 조직으로 성장할 수 있을 것같습니다.
'Framework > NestJS' 카테고리의 다른 글
| 상반기 WMS 프로젝트 회고: 전략 패턴(Strategy Pattern) 적용기 (2) | 2025.06.15 |
|---|---|
| DDD 설계: Use Case 기반의 Application Layer 구현과 트랜잭션 관리 (2) | 2025.06.12 |
| NestJs AOP - @toss/nestjs-aop vs 함수형 프로그래밍 패러다임 (0) | 2025.04.06 |
| NestJS의 Provider vs Spring의 Component: 등록 및 관리 방식의 핵심 차이 및 분석 (1) | 2025.03.23 |
| NestJs는 AOP를 지원할까? Spring AOP와의 비교 분석 (0) | 2025.03.03 |