-
clean Architecturejava 2026. 1. 20. 11:10
Task를 나누는 법
"들어온 Task 리스트를 순회하며, 알맞은 Agent에게 할당하는 함수"
// 1. 인터페이스 정의 interface TaskDispatcher { void dispatch(Task task); } // 2. 구현 (로직) class AgentTaskDispatcher implements TaskDispatcher { private final List<Agent> agents; public void dispatch(Task task) { // 적절한 에이전트 찾기 (Filter) Agent bestAgent = agents.stream() .filter(agent -> agent.canHandle(task.getType())) .findFirst() .orElseThrow(() -> new NoAgentAvailableException()); // 에이전트에게 할당 (Action) bestAgent.assign(task); System.out.println("Task assigned to " + bestAgent.getName()); } }더보기
"Task를 받아서, 적절한 Agent에게 작업을 시키는 로직. Agent는 HTTP로 호출한다고 가정 (심화버전)"import java.util.*; // [Domain] 핵심 비즈니스 객체 class Task { String id; String description; public Task(String id, String description) { this.id = id; this.description = description; } } // (Outbound Port) 외부 세상과 소통하는 문 (인터페이스) // 그냥 '시킨다'. Just Do It. " interface LoadAgentPort { List<String> findAvailableAgents(); } interface CommandAgentPort { void sendCommand(String agentId, Task task); }// (Application Service) 오케스트레이터 // "오직 인터페이스(Port)에만 의존. HTTP 관련 코드는 작성하지 않는다." class TaskOrchestrator { private final LoadAgentPort loadPort; private final CommandAgentPort commandPort; public TaskOrchestrator(LoadAgentPort loadPort, CommandAgentPort commandPort) { this.loadPort = loadPort; this.commandPort = commandPort; } public void assignTask(Task task) { // 1. 가용 에이전트 조회 List<String> agents = loadPort.findAvailableAgents(); if (agents.isEmpty()) throw new RuntimeException("No Agents!"); // 2. 로드밸런싱 로직 (단순히 첫 번째 선택) String bestAgent = agents.get(0); // 3. 명령 전송 commandPort.sendCommand(bestAgent, task); System.out.println("Core Logic: Task assigned to " + bestAgent); } }// (Infrastructure Adapter) 실제 기술 구현 // "* 여기서 RestTemplate이나 WebClient를 쓰겠지만, Mock으로 대체." class HttpAgentAdapter implements LoadAgentPort, CommandAgentPort { @Override public List<String> findAvailableAgents() { // 실제 API 호출 로직~~~~~~ return Arrays.asList("Agent-A", "Agent-B"); } @Override public void sendCommand(String agentId, Task task) { // 실제 HTTP POST 요청 로직~~~~~~ System.out.println("[HTTP Adapter] Sending POST to " + agentId + " with payload: " + task.description); } }public class Main { public static void main(String[] args) { // 1. 어댑터 생성 HttpAgentAdapter httpAdapter = new HttpAgentAdapter(); // 2. 서비스에 주입 (의존성 주입 - DI) TaskOrchestrator orchestrator = new TaskOrchestrator(httpAdapter, httpAdapter); // 3. 실행 orchestrator.assignTask(new Task("1", "Book a hotel")); } }-----------------------------------------------------------------------------------------
TDD
// MainTest.java public class TaskOrchestratorTest { //테스트 목표: "Task가 주어지면, 가용한 에이전트를 찾아서 명령을 내려야 한다." public void testAssignTaskSuccess() { // 1. [Given] 상황 설정 // 가짜(Mock) 포트. MockLoadAgentPort loadPort = new MockLoadAgentPort(); MockCommandAgentPort commandPort = new MockCommandAgentPort(); // 우리가 테스트할 핵심 도메인 서비스 TaskOrchestrator orchestrator = new TaskOrchestrator(loadPort, commandPort); Task task = new Task("task-1", "Search Flight"); // 2. [When] 실행 orchestrator.dispatch(task); // 3. [Then] 검증 // "명령이 실제로 전송되었는가?"를 확인 if (!commandPort.isCommandSent()) { throw new RuntimeException("Test Failed: Command was not sent!"); } System.out.println("Success Test Passed"); } }// 1. 도메인 객체 class Task { String id; String type; public Task(String id, String type) { this.id = id; this.type = type; } } // 2. (Port) 인터페이스 정의 interface LoadAgentPort { String findBestAgent(String taskType); } interface CommandAgentPort { void sendCommand(String agentId, Task task); } // 3. (Service) 도메인 로직 구현 class TaskOrchestrator { private final LoadAgentPort loadPort; private final CommandAgentPort commandPort; public TaskOrchestrator(LoadAgentPort loadPort, CommandAgentPort commandPort) { this.loadPort = loadPort; this.commandPort = commandPort; } public void dispatch(Task task) { // 에이전트 찾기 String agentId = loadPort.findBestAgent(task.type); // 에이전트가 없으면 예외 처리 if (agentId == null) throw new RuntimeException("No Agent Found"); // 명령 전송 commandPort.sendCommand(agentId, task); } }// 테스트를 위한 가짜 어댑터 (Mock/Stub) class MockLoadAgentPort implements LoadAgentPort { @Override public String findBestAgent(String taskType) { return "Agent-007"; // 무조건 에이전트를 찾았다고 가정 } } class MockCommandAgentPort implements CommandAgentPort { private boolean commandSent = false; @Override public void sendCommand(String agentId, Task task) { this.commandSent = true; // 호출되었다는 사실을 기록 System.out.println("Mock: Command sent to " + agentId); } public boolean isCommandSent() { return commandSent; } } // 실행 public class Main { public static void main(String[] args) { new TaskOrchestratorTest().testAssignTaskSuccess(); } }public void testNotAgentFound() { MockLoadAgentPort emptyPort = new MockLoadAgentPort() { @Override public String findBestAgent(String taskType) { return null; // 에이전트가 없음 } }; TaskOrchestrator orchestrator = new TaskOrchestrator(emptyPort, new MockCommandAgentPort()); try { orchestrator.dispatch(new Task("2", "Unknown Task")); throw new RuntimeException("Test Failed: Should have thrown exception"); } catch (RuntimeException e) { System.out.println("Exception Test Passed: " + e.getMessage()); } }Long Polling 이란?
@RestController public class LongPollingController { // 요청을 대기하고 있는 객체들을 저장소에 보관 private final Map<String, DeferredResult<Task>> watchList = new ConcurrentHashMap<>(); // Agent가 호출하는 메서드 "일 있어요?" @GetMapping("/api/tasks/poll") public DeferredResult<Task> pollTask(@RequestParam String agentId) { // 30초 타임아웃 설정 DeferredResult<Task> output = new DeferredResult<>(30000L); // 타임아웃 발생 시 "일 없음" 응답 output.onTimeout(() -> { watchList.remove(agentId); output.setResult(null); // 204 No Content }); // 맵에 저장해두고 대기 (쓰레드는 반납됨 - Non-blocking) watchList.put(agentId, output); return output; } // (이벤트 발생) Kafka Consumer가 호출하는 메서드 public void onNewTask(Task task) { String targetAgentId = task.getAgentId(); // 기다리고 있는 요청이 있다면 즉시 응답값 주입 if (watchList.containsKey(targetAgentId)) { DeferredResult<Task> waitingRequest = watchList.remove(targetAgentId); waitingRequest.setResult(task); // 이때 클라이언트로 응답이 날아감 } } }전통적인 방식
controller ->service -> repository -> database
이 구조로 개발을 진행하다 몇가지 생각이 들었다.
1. Service가 Repository를 너무 의존한다.
- 이로 인해 기존에 사용하던 DB를 변환하려면 많은 수정이 필요한것 같다.
2. 도메인 이 아닌 서비스 로직을 작성하기 급급하다.
- 회원 테이블에 어떤 컬럼이 필요한지 생각하고 객체를 생성해 다루는 서비스를 만든다.
- 이때 비즈니스 로직은 대부분 DB에서 데이터를 꺼내고 넣는 수준의 로직이 많다...심지어 중복코드도 많아짐.
3. TDD가 어려워졌다.
- 서비스 파일에 기능을 찾기도 어렵고. 그 서비스를 테스트 하기도 어려워졌다.
5. 파일 네이밍도 어려움. AccountService였나...? AssetService였나...? 라고 햇갈리는 문제..
6. 확장이 어렵다......업그레이드, 기능 추가등 변경되는 비즈니스 로직이 두렵다. 코드를 수정하기 너무 어려워졌다.
이런 문제를 겪으며 리펙토링이 과연 무엇인가 생각을 하다 헥사고날 아키텍쳐라는것을 접했고, 이 아키텍쳐를 공부하며 내 서비스의 코드를 어떻게 고쳐야할지 고민중이다.
근데 왜 헥사고날을 쓰려하나?
자주 변경되는 에이전트들... 인프라... 의존성이 너무 높은 코드들.. 변경의 두려움..
하지만 헥사고날은 실제 로직을 분리하고, 이 자주 변경되는 에이전트들과 인프라를 쉽게 갈아끼우며 테스트가 용이하게 변경될 수 있다.
이로인해 여러 에이전트들을 내 서비스에 붙여보며 쉽게 테스트하고 갈아 끼울수 있는것임.
근데 이거 단점? 그렇다고 굳이 헥사고날로 변경을 해야할까.
일단 학습하고 내 아키텍쳐에 적용해보려면 상당히 많은 시간이 소요될 것 전통적인 방식 또한 무지한 상태에서 오랜시간에 걸쳐 학습을 했다. 헥사고날에서 CRUD를 만들면
Controller → WebAdapter → InPort → Service → OutPort → PersistenceAdapter → Repository................
수많은 파일이 생겨 위에 고민했던 어떤 기능의 파일인지, 비즈니스 로직은 어디에 있는것인지;
또한 매핑도 기본 3개씩.. 웹, DB, 도메인.......
수많은 에이전트... 통신 프로토콜들이 변경되는데... 초기 구조만 잡아놓으면 편하겠지만 프로젝트가 방대해져버린 상태에서 아키텍쳐를 변경할 수 있을지 두렵다.
헥사고날 아키텍쳐
핵심 : 내부(순수 비즈니스로직, java)와 외부(인프라, 외부 API, 메시지큐 등...)를 분리해, 실질적인 코어 비즈니스 로직은 보호하되 외부가 확장성 있게 변경가능.
개념 :
1. 내부
- 절대규칙이 있다. 내부는 외부에 대해 아무것도 몰라야한다. 즉, 의존성이 없어야한다.
2. 외부
- 언제든지 교체하여 사용가능하게 끔.
3. 포트와 어뎁터
- 외부와 내부를 연결해주는 부분.
포트 - 인터페이스 (Interface) :
내부가 외부와 소통?통신하기 위한 규격. 약속. 따라서 자바의 인터페이스이다.
- Inbound Port : Core를 사용하려면 특정 인터페이스를 통해 작동시켜야함.
public interface TaskUseCase { void createTask(String command); }@Service public class TaskService implements TaskUseCase { // Outbound Port(인터페이스)에만 의존!!! 실제 DB가 뭔지 모름. private final SaveTaskPort saveTaskPort; public TaskService(SaveTaskPort saveTaskPort) { this.saveTaskPort = saveTaskPort; } @Override public void createTask(String command) { Task task = new Task(command); // 도메인 객체 생성 // 비즈니스 로직 수행... saveTaskPort.save(task); // 저장해줭 } }- Outbound Port : Core는 특정 인터페이스를 통해 데이터를 저장하거나 외부를 부른다.
public interface SaveTaskPort { void save(Task task); }어뎁터 - 구현체(Implementation ) :
포트에 맞춰 실제로 동작하는 부분. 바깥쪽에 해당하는것이다.
- Inbound Adapter (Driving Adapter) : 사용자의 요청을 받아 Inbound Port를 호출함.
- Outbound Adapter(Driven Adapter) : Outbound Port를 구현해서 실제 DB나 외부 시스템을 호출?작동?
@Component public class TaskPersistenceAdapter implements SaveTaskPort { private final SpringDataJpaRepository repository; // JPA 사용 @Override public void save(Task task) { // 도메인 객체를 DB 엔티티로 변환 후 저장 repository.save(new TaskJpaEntity(task)); } }더보기ex) JPA Repository, Kafka Producer, External API Client 라는게 Outbound Adapter에 해당한다. 제미나이 피셜
제미나이가 짜준 패키지 구조
com.agent.system
├── domain (핵심 로직 - 가장 안쪽)
│ ├── Task.java (Entity)
│ └── TaskStatus.java
├── application (비즈니스 흐름)
│ ├── port
│ │ ├── in (Inbound Port)
│ │ │ └── CreateTaskUseCase.java (Interface)
│ │ └── out (Outbound Port)
│ │ ├── LoadTaskPort.java (Interface)
│ │ └── SaveTaskPort.java (Interface)
│ └── service
│ └── TaskService.java (CreateTaskUseCase 구현)
└── adapter (바깥쪽 - 세부 기술)
├── in (Driving)
│ └── web
│ ├── TaskController.java
│ └── TaskRequest.java
└── out (Driven)
├── persistence (DB)
│ ├── TaskJpaEntity.java
│ └── TaskPersistenceAdapter.java (SaveTaskPort 구현)
└── kafka (Messaging)
└── TaskEventProducer.java간단한 예제를 만들어 보았다.
// ====================================================== // 1. (Core/Domain) - 가장 안쪽 세상 // 여기는 Spring도 없고, DB도 모르고, 오직 비즈니스 로직만 넣는곳이다. // ====================================================== // (Service) 비즈니스 로직 public class TravelService { // ★ Service는 "인터페이스"만 들고 있다!!! // 이게 MySQL인지, 파일인지, 외부 API인지 몰루 private final SavePlanPort savePlanPort; public TravelService(SavePlanPort savePlanPort) { this.savePlanPort = savePlanPort; } public void createPlan(String dest) { // ~~~~여행 계획 짜는 복잡한 로직~~~~~ TravelPlan plan = new TravelPlan(dest); savePlanPort.save(plan); } } // (Port) 나가는 구멍 (Interface) public interface SavePlanPort { void save(TravelPlan plan); } // ========================================== // 2. (Infrastructure) - 바깥 세상 // DB, Kafka, API 등 구체적인 기술이 있는 곳 // ========================================== // (Adapter) 실제 구현체 // ★ 바깥쪽(Infra)이 안쪽(Domain)의 인터페이스를 구현(Implements)하기. // 의존성 화살표 [Infra -> Domain] @Repository public class MysqlAdapter implements SavePlanPort { private final JpaRepository jpaRepo; @Override public void save(TravelPlan plan) { // 도메인 객체를 DB 엔티티로 변환해서 저장 jpaRepo.save(toEntity(plan)); System.out.println("MySQL에 저장 완료!!!"); } }조금 더 짧은, 하지만 자세한 설명 버전으로 작성해보았다.
'java' 카테고리의 다른 글
JAVA_STUDY_10 (0) 2021.05.04 Study_Java_03 (0) 2021.03.30 Study_Java_02 (0) 2021.03.29 Study_Java_01 (0) 2021.02.18