백엔드-구조-개선하기
들어가기 앞서
회사에 입사하고 여러가지 문제점들을 빠르게 파악했습니다. 그 중 가장 큰 문제는 SI 업계에서 흔히 있는 거래처 별 소스 코드가 전부 다르게 수정되어 있고, 표준이 없다는 점이였습니다. 기존 저희 회사의 업무 프로세스는 다음과 같았습니다.
- 새로운 프로젝트 수주
- 기존 다른 프로젝트에서 사용한 소스코드 복사 붙여넣기
- 새로운 프로젝트에서 요구하는 기존 기능의 수정 사항이나 추가 기능을 개발
기존의 업무 프로세스도 만약 선형으로 버전 관리가 된다면 괜찮을 수 있겠지만, 버전 관리도 따로 하지 않았습니다. 결국 각 거래처에 제공된 서버 소스코드는 요구사항이나 개발을 맡은 개발자의 스타일 대로 모두 달랐기 때문에 정말 전혀 다른 소스라고 봐도 될 정도였죠. (이어 사내에서 표준화를 위한 문서나 규칙도 없었기 때문에 그 정도는 더 심했죠.) 저는 이러한 구조가 매우 큰 생산성 저하를 일으킨다고 생각했습니다. 중복 코드를 작성할 수도 있고, 개발자는 본인이 맡은 프로젝트에 대한 결합도가 높아 다른 사람과 협업을 하려면 굉장히 큰 커뮤니케이션 비용이 발생했습니다. 심지어는 DB 컬럼명도 서로가 다르게 쓸 정도였으니까요. 그래서 저는 이러한 문제점들을 해결할 수 있는 백엔드 구조를 만들고 싶었습니다.
자 그럼 어떻게 해야할까?
저는 가장 먼저 ‘스탠다드’ 서버와 ‘커스텀’ 서버를 분리하고 싶었습니다.
- 스탠다드 서버: 표준 Database를 바라보고, 표준으로 설계된 비즈니스 로직으로 작동하는 우선순위가 높은 서버
- 커스텀 서버: 각 거래처의 개별 Database를 바라보고, 거래처에 최적화된 비즈니스 로직으로 작동하는 우선순위가 낮은 서버
가장 먼저 사용자의 클라이언트에서 스탠다드 서버로 요청을 보내고 표준화된 인증 모듈이 유효한 요청인지 검증합니다. 이후 유효하다면 사용자는 인증 정보를 액세스 토큰을 받고, 자신의 CompanyId를 식별할 수 있게 됩니다. 이 CompanyId는 각 사용자가 속한 회사를 식별하는 아이디로 이것을 통해 사용자가 어떤 커스텀 서버로 가야 하는지를 알 수 있습니다. 두 가지 경우에 사용자의 요청은 커스텀 서버로 전이 되어야 합니다:
- 스탠다드 서버가 바라보는 표준 데이터베이스에 의존하지 않는 API 요청일 경우
- 스탠다드 서버에서 사용하지 않는 표준 비즈니스 로직에 의존하지 않는 API 요청일 경우
따라서 client에서 커스텀 서버로 전이가 필요한 API 요청일 경우 request header 속성에 needsCustomProcessing
이 true인 상태로 스탠다드 서버로 요청을 보내면, 스탠다드 서버의 미들웨어에서 이를 처리하고 커스텀 서버로 전이시켜 주는 구조를 계획했습니다.
헥사고날 아키텍처 도입
다만 이렇게 계획한 구조의 경우 가장 큰 주안점이 있었습니다. 바로 스탠다드 서버가 커스텀 서버에 의존하면 안된다는 점이였는데요. 왜냐하면 스탠다드 서버는 많은 거래처들이 동시에 도달하거나 경유하는 API 서버였기 때문입니다. 각 거래처에 의존한다면 스탠다드 서버는 그 의미를 잃어버리겠죠. 이것을 주의하며 구현하기 위해 가장 먼저 헥사고날 아키텍처(Hexagonal Architecture)의 스탠다드 서버를 구현했습니다.
헥사고날 아키텍처란?
헥사고날 아키텍처는 애플리케이션의 핵심 로직을 외부 요소(UI, 데이터베이스 등)와 분리하는 소프트웨어 설계 패턴입니다. 이 아키텍처의 핵심 개념은 ‘포트(Port)’와 ‘어댑터(Adapter)’입니다:
- 포트: 애플리케이션과 외부 세계가 소통하는 인터페이스
- 어댑터: 포트를 통해 애플리케이션과 외부 기술을 연결하는 컴포넌트
구현 내용
스탠다드 서버는 커스텀 서버의 응답에 의존성을 가지면 안됐기 때문에, 헥사고날 아키텍처를 적용해 다음과 같이 구현했습니다:
- 스탠다드 서버를 application 계층과 adapter 계층으로 분리
- application 계층과 외부 계층을 연결하는 port 인터페이스를 만들고, port의 구현체로 adapter를 정의해서 의존성이 분리된 결합을 사용
- 인바운드 포트와 아웃바운드 포트를 구분
- usecase 인터페이스: 인바운드 포트로 사용
- port 인터페이스: 아웃바운드 포트로 사용
- application 계층에 service 구현체를 만들어서 아웃바운드 포트를 상속받고 비즈니스 로직을 결합
- usecase 인터페이스는 controller 구현체에 상속되어 인바운드 포트로서 비즈니스 로직 결합
아래는 간단한 코드 예시입니다:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 아웃바운드 포트 - 외부 시스템과의 통신을 위한 인터페이스
public interface CustomServerPort {
ResponseDto processCustomRequest(RequestDto request, String companyId);
}
// 아웃바운드 어댑터 - 실제 커스텀 서버와 통신하는 구현체
@Component
public class CustomServerAdapter implements CustomServerPort {
private final CustomServerClient client;
public CustomServerAdapter(CustomServerClient client) {
this.client = client;
}
@Override
public ResponseDto processCustomRequest(RequestDto request, String companyId) {
return client.sendRequest(request, companyId);
}
}
// 인바운드 포트 - 애플리케이션 사용 사례 정의
public interface UserService {
ResponseDto handleRequest(RequestDto request, String companyId, boolean needsCustomProcessing);
}
// 애플리케이션 서비스 - 비즈니스 로직 구현
@Service
public class UserServiceImpl implements UserService {
private final CustomServerPort customServerPort;
public UserServiceImpl(CustomServerPort customServerPort) {
this.customServerPort = customServerPort;
}
@Override
public ResponseDto handleRequest(RequestDto request, String companyId, boolean needsCustomProcessing) {
if (needsCustomProcessing) {
return customServerPort.processCustomRequest(request, companyId);
}
// 표준 비즈니스 로직 수행
return processStandardRequest(request);
}
private ResponseDto processStandardRequest(RequestDto request) {
// 표준 처리 로직
// ...
return new ResponseDto();
}
}
// 인바운드 어댑터 - 컨트롤러
@RestController
@RequestMapping("/api")
public class ApiController {
private final UserService userService;
public ApiController(UserService userService) {
this.userService = userService;
}
@PostMapping("/request")
public ResponseEntity<ResponseDto> handleRequest(
@RequestBody RequestDto request,
@RequestHeader("X-Company-Id") String companyId,
@RequestHeader(value = "X-Needs-Custom-Processing", defaultValue = "false") boolean needsCustomProcessing) {
ResponseDto response = userService.handleRequest(request, companyId, needsCustomProcessing);
return ResponseEntity.ok(response);
}
}
이후 FeignClient를 활용해서 스탠다드 서버에 커스텀 서버에 대한 정의 파일을 만들어 놓기만 하면 커스텀 서버와의 연동은 가능했습니다:
1
2
3
4
5
@FeignClient(name = "custom-server", url = "${custom-server.url}")
public interface CustomServerClient {
@PostMapping("/api/process")
ResponseDto sendRequest(@RequestBody RequestDto request, @RequestHeader("X-Company-Id") String companyId);
}
구현 결과 및 장점
헥사고날 아키텍처를 적용한 후 다음과 같은 장점을 얻을 수 있었습니다:
- 코드 표준화: 모든 프로젝트가 동일한 구조와 패턴을 따르게 되어 코드 품질이 향상되었습니다.
- 의존성 분리: 스탠다드 서버는 커스텀 서버의 구현 세부사항에 의존하지 않고, 인터페이스를 통해 통신하게 되었습니다.
- 유연성 향상: 새로운 거래처가 추가되더라도 스탠다드 서버를 수정할 필요 없이 커스텀 서버만 추가하면 됩니다.
- 테스트 용이성: 의존성이 분리되어 있어 모의 객체(Mock)를 사용한 단위 테스트가 쉬워졌습니다.
- 유지보수성 향상: 각 계층과 컴포넌트의 책임이 명확해져 유지보수가 쉬워졌습니다.
결론
헥사고날 아키텍처를 도입함으로써 기존의 코드 중복과 표준화 부재 문제를 효과적으로 해결할 수 있었습니다. 이제 새로운 거래처가 추가되더라도 표준 비즈니스 로직은 그대로 재사용하고, 커스텀 로직만 별도로 구현하면 되기 때문에 개발 생산성이 크게 향상되었습니다. 또한 이 구조는 마이크로서비스 아키텍처로의 확장도 쉽게 할 수 있어, 앞으로의 시스템 확장에도 대비할 수 있게 되었습니다. 무엇보다 개발자들이 표준화된 구조 안에서 일관된 방식으로 코드를 작성할 수 있게 되어 협업 효율성도 크게 향상되었습니다. 이는 결국 회사 전체의 소프트웨어 개발 프로세스 품질 향상으로 이어졌습니다.