외부 API 연동 구조 설계하기 : 비즈니스 로직 / 어댑터 / API Client 계층 분리
외부 기관 API와 연동하는 기능을 개발하면서, 왜 한 클래스에 다 넣지 않고 비즈니스 로직·어댑터·API Client로 계층을 나눴는지 정리했습니다.
외부 API 연동 구조 설계하기 : 비즈니스 로직 / 어댑터 / API Client 계층 분리
업무 중에 외부 기관 API와 연동하는 기능을 개발할 일이 있었습니다. 처음엔 “그냥 API 호출하는 메서드 하나 만들면 되는 거 아닌가?” 싶었는데, 막상 짜다 보니 한 클래스에 다 몰아넣는 것과 계층을 나누는 것 사이에서 고민하게 됐고, 결국 비즈니스 로직 / 어댑터 / API Client로 나눠서 개발했습니다. 왜 이렇게 나눴는지 정리해봅니다.
처음에 다 합쳤을 때 생기는 문제
가장 빠른 방법은 서비스 클래스 하나에 다 넣는 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Service
public class DeclarationService {
private final RestTemplate restTemplate;
public DeclarationResult submitDeclaration(Declaration declaration) {
// 1. 비즈니스 규칙 검증
if (declaration.getItems().isEmpty()) {
throw new IllegalArgumentException("신고 항목이 비어있습니다.");
}
// 2. 외부 API 요청 포맷으로 변환
ExternalRequestDto request = new ExternalRequestDto();
request.setDeclarantCode(declaration.getCompanyCode());
request.setItems(declaration.getItems().stream()
.map(i -> new ExternalItemDto(i.getName(), i.getQuantity()))
.toList());
// 3. 실제 HTTP 호출 + 인증 헤더 세팅
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + issueAccessToken());
HttpEntity<ExternalRequestDto> entity = new HttpEntity<>(request, headers);
ResponseEntity<ExternalResponseDto> response = restTemplate.postForEntity(
"https://external-agency.example.com/api/declarations", entity, ExternalResponseDto.class);
// 4. 응답을 다시 우리 도메인 모델로 변환
ExternalResponseDto body = response.getBody();
if (body == null || !"OK".equals(body.getStatus())) {
throw new DeclarationFailedException(body != null ? body.getErrorMessage() : "응답 없음");
}
return new DeclarationResult(body.getDeclarationNo(), body.getProcessedAt());
}
}
한 메서드 안에 비즈니스 규칙 검증, 도메인 ↔ 외부 API 모델 변환, HTTP 통신/인증 디테일이 전부 섞여 있습니다. 코드 자체는 동작하지만, 실제로 겪은 문제는 이랬습니다.
- 외부 API 응답 필드 하나가 바뀌어도
DeclarationService를 수정해야 해서, 비즈니스 로직과 무관한 변경인데도 서비스 클래스 전체를 다시 들여다봐야 했습니다. - 신고 항목 검증 같은 순수 비즈니스 규칙을 테스트하려면
RestTemplate까지 같이 물려 있어서, HTTP 호출을 mock 처리하지 않으면 단위 테스트가 안 됐습니다. - 메서드가 길어질수록 “이게 우리 도메인 규칙인지, 아니면 그냥 이 API가 요구하는 특이한 스펙인지” 구분이 점점 어려워졌습니다.
계층을 나누기
그래서 역할을 세 가지로 나눴습니다.
graph LR
Biz[비즈니스 로직] -->|Port 인터페이스만 의존| Port[[DeclarationPort]]
Port --> Adapter[어댑터]
Adapter -->|도메인 ↔ 외부 DTO 변환| Client[API Client]
Client -->|HTTP 통신, 인증, 재시도| External[(외부 기관 API)]
- 비즈니스 로직: 신고 항목 검증 같은 도메인 규칙만 다룹니다. 외부 API가 REST인지, 어떤 인증 방식을 쓰는지 전혀 몰라도 됩니다.
DeclarationPort라는 인터페이스에만 의존합니다. - 어댑터:
DeclarationPort의 구현체. 도메인 모델(Declaration)을 외부 API가 요구하는 형태로 바꾸고, 응답을 다시 도메인 모델(DeclarationResult)로 바꿉니다. - API Client: 실제 HTTP 요청, 인증 토큰 발급, 타임아웃/재시도 같은 통신 디테일만 담당합니다. 도메인 모델은 전혀 모릅니다.
비즈니스 로직 — Port 인터페이스에만 의존
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface DeclarationPort {
DeclarationResult submit(Declaration declaration);
}
@Service
@RequiredArgsConstructor
public class DeclarationService {
private final DeclarationPort declarationPort;
public DeclarationResult submitDeclaration(Declaration declaration) {
if (declaration.getItems().isEmpty()) {
throw new IllegalArgumentException("신고 항목이 비어있습니다.");
}
return declarationPort.submit(declaration);
}
}
DeclarationService는 이제 외부 API의 존재 자체를 모릅니다. 테스트할 때는 DeclarationPort를 mock으로 대체하면 되니, 비즈니스 규칙 검증만 순수하게 단위 테스트할 수 있습니다.
어댑터 — 도메인 모델과 외부 API 모델 사이 변환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
@RequiredArgsConstructor
public class ExternalDeclarationAdapter implements DeclarationPort {
private final ExternalDeclarationClient client;
@Override
public DeclarationResult submit(Declaration declaration) {
ExternalRequestDto request = toExternalRequest(declaration);
ExternalResponseDto response = client.postDeclaration(request);
if (!"OK".equals(response.getStatus())) {
throw new DeclarationFailedException(response.getErrorMessage());
}
return new DeclarationResult(response.getDeclarationNo(), response.getProcessedAt());
}
private ExternalRequestDto toExternalRequest(Declaration declaration) {
ExternalRequestDto request = new ExternalRequestDto();
request.setDeclarantCode(declaration.getCompanyCode());
request.setItems(declaration.getItems().stream()
.map(i -> new ExternalItemDto(i.getName(), i.getQuantity()))
.toList());
return request;
}
}
API Client — 통신 디테일만 담당
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
@RequiredArgsConstructor
public class ExternalDeclarationClient {
private final RestTemplate restTemplate;
private final TokenProvider tokenProvider;
public ExternalResponseDto postDeclaration(ExternalRequestDto request) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + tokenProvider.getAccessToken());
HttpEntity<ExternalRequestDto> entity = new HttpEntity<>(request, headers);
ResponseEntity<ExternalResponseDto> response = restTemplate.postForEntity(
"https://external-agency.example.com/api/declarations", entity, ExternalResponseDto.class);
return response.getBody();
}
}
이렇게 나눠서 얻은 것
- 비즈니스 로직 단위 테스트:
DeclarationPort를 mock으로 대체하면 네트워크 없이도 검증 로직만 빠르게 테스트할 수 있었습니다. - 변경 격리: 외부 API 응답 필드가 바뀌면 어댑터만 고치면 되고, 신고 항목 검증 로직에는 영향이 없습니다. 반대로 검증 규칙이 바뀌어도 통신 코드는 그대로입니다.
- 책임이 분명해짐: 코드 리뷰할 때 “이건 우리 도메인 규칙인지, 외부 API 스펙 때문인지”를 계층만 보고 바로 구분할 수 있게 됐습니다.
그래도 남는 고민
계층을 나누는 만큼 인터페이스, DTO, 구현체까지 파일 수는 확실히 늘어납니다. 정말 한 번 쓰고 버릴 스크립트 수준이라면 이 정도 구조는 과할 수 있습니다. 하지만 실제 서비스 코드에 들어가고, 언젠가 이 외부 시스템의 스펙이 바뀌거나 테스트가 필요해질 가능성이 있다면, 처음부터 계층을 나눠두는 쪽이 결국 더 적은 시간을 쓰게 만든다고 느꼈습니다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.