🥺 나의 단순 CRUD API 해방일지
숫자 하나만 수정하면 되는 간단한 작업을 마친 후, 다른 팀원들과 달리 할 일이 없던 나에게 단순 CRUD 작업의 이관 프로젝트가 왔다. 기존 어드민 API 중 일부를 내부 서버로 옮기면 되는 일이었다.
처음에는 가벼운 프로젝트로 시작했지만, 예상보다 수정 범위와 과제가 복잡했다. 중간에 새로운 API가 추가되기도 하고 요구사항 변경도 있었다. 결국 API 운영 서버 오픈까지 3개월이라는 긴 시간이 소요되었다. 당시 겪었던 여러 고민과 문제 해결을 기록으로 남기고자 한다.
프로젝트를 진행하며 했던 고민들이 많은데, 한 포스트에 다 쓰기엔 분량이 있을 것 같아 주제를 나눠 작성하려 한다. 이번에는 API 이관 과정 중 DTO 관련 리팩토링을 진행하며 겪었던 일들을 다룬다.
단순 CRUD인데요, 다만 개수가 26개인
단순 CRUD가 무엇일까?
CRUD는 데이터 처리에서 가장 기본적인 작업을 나타내는 용어로, Create(생성), Read(읽기), Update(수정), Delete(삭제)의 첫 글자들을 따서 만든 약자이다.
대부분의 소프트웨어나 웹 애플리케이션에서 데이터베이스와 상호작용하는데 필수적인 기능이다. 따라서 우리가 작성하는 대부분의 API는 CRUD 중 하나일 것이며, 한 API에서 여러 작업(C+R)이 발생한다면 명령-쿼리 분리 원칙에 위배될 수도 있다.
이런 의미에서 프로젝트에서 이관해야 하는 코드들은 당연히 CRUD 작업일 것이다. 지금까지 작성한 구직용 포트폴리오의 코드들 역시 CRUD 였다. 포트폴리오 내 API 개수를 다 합쳐도 30개가 채 되지 않는다.
이러한 관점에서 첫 프로젝트로는 다소 무섭게 느껴졌다. 물론 기존 코드의 이관인 만큼, 컨트롤러 단의 메서드들을 그대로 옮기면 된다고 생각할 수도 있다. 하지만 여기엔 문제가 있었다. 어떤 API를 이관해야 하는지 알 수 없었고, API를 타고 들어가면 여러 테이블을 조인해 연쇄적으로 생성/수정하거나 복잡한 조건절로 구성되어 있었다.
API들을 파악하고 각 역할을 Swagger 문서화로 정리하는 과정이 쉽진 않았지만, 덕분에 팀 내 코어 도메인들에 대한 이해도를 높일 수 있어 좋았다. 운영 테스트 중 이관한 API에 대한 질문에 제대로 답하지 못할 때마다 부끄러웠던 기억이 있다. 앞으로 각 API들의 동작 방식들을 남은 테스트코드를 짜면서 머릿속에 정리해나가고 싶다.
특정 페이지에서 사용되는 API를 모두 옮기는 법
맡았던 업무는 사내 관리시스템을 위한 API 제공하는 일이었다. 관리시스템에 필요한 API를 결정하는 과정에서, 특정 어드민 페이지의 모든 API를 옮겨와달라는 테스크를 받았다.
당시 내 생각에는, 특정 페이지에서 사용되는 API를 찾아내기 위한 방법이 크게 두 가지가 있었다.
첫번째 방법은 모든 버튼을 클릭하며 CRUD 작업을 실행한 후, 개발자 도구를 통해 호출되는 요청을 살펴보는 것이었다. 두번째 방법은 해당 어드민 페이지의 프론트 코드를 직접 확인하여 모든 URL을 찾아보는 것이었다.
Vue 코드를 해석할 수 없던 백엔드 개발자였기에, 첫번째 방법을 선택했다… 그 결과, 일부 API가 누락되거나 필요 없는 API가 이관되거나, 원래 호출과 관련 없는 API가 이관되는 문제가 발생했다.
여러 차례의 피드백을 거쳐 최종적으로 이관해야 할 기존 API를 26개로 결정했다. 각 API의 기능이 헷갈리지 않도록 노션에 세심하게 기록해두었다. 다음번 API 이관 작업에서는 Swagger를 활용하여 API에 대한 설명과 DTO 필드의 용도를 상세하게 기록함으로써 문서와 코드 사이의 거리를 줄이는 것이 좋을 것 같다.
어드민 모듈 컨트롤러 내의 DTO가 공통 모듈에 위치한 건에 대하여
그렇게 3주가 지났다. 다행히 정기회의에서 해당 API들을 그대로 사용해도 괜찮다는 결론이 나왔다. 그러나 새로운 문제가 발생했다. 바로 이관 과정에서였다.
어드민 컨트롤러 메소드의 DTO 안에 로직이 포함되어 있었고, JPA 엔티티까지 연결되어 있었다. 즉, DTO와 비즈니스 로직 사이의 결합도가 높았다. 더 큰 문제는 모듈 간 결합도였다. 컨트롤러가 위치한 곳은 어드민 모듈이고, JPA 리포지토리와 명령/쿼리 서비스를 담당하는 공통 모듈은 별도로 분리되어 있었다. 그런데 DTO와 엔티티 사이의 결합도로 인해, 어드민 모듈에 속해야 할 DTO가 공통 모듈에 존재했다.
즉, 어드민에서 공통 모듈에 있는 request를 사용해 요청 값을 가져오고, 그 값을 어드민 모듈의 서비스가 처리하기 위해 다시 공통 모듈을 호출하고 있었다.
따라서 인터널 모듈에서 새로운 API들의 DTO 위치도 공통 모듈이어야 했다.
DTO 생성 위치를 바꾸다
또 다른 문제가 생겼다. 어드민 모듈에서 사용되는 DTO를 인터널 모듈에서 그대로 재사용하지 않는 것이 좋다는 코드 리뷰를 받았고 이에 동의했다. 그런데 인터널 모듈에서 사용될 DTO와 어드민 모듈의 DTO의 이름이 같아야 했지만, 공통 모듈에서 동시에 존재할 수는 없었다. Interner~Request
와 같은 접두사를 붙이는 것을 고려했지만, 좋은 해결 방안이 아니라고 생각했다.
이 문제를 해결하기 위해 기존 DTO를 엔티티로 변환하는 Command 서비스를 함께 이관하고, Command로 바꾸는 위치를 인터널 모듈 안으로 변경해야 했다.
@Service
public class ExampleAppService {
public ExampleAppService(ExampleQueryService queryService, ExampleMapper exampleMapper) {
this.queryService = queryService;
this.ExampleMapper = ExampleMapper;
}
public PagedResponse list(SearchRequest request, Pageable pageable) {
// 이제 공통모듈 속 queryService 는 인터널 모듈 속 SearchRequest dto를 알지 못하게 된다.
return PagedResponse.from(queryService.list(exampleMapper.getCommand(request), pageable));
}
//(...)
}
@Component
public class ExampleMapper {
public ExampleCommand getCommand(SearchRequest searchRequest) {
return ExampleCommand.builder()
.itemIds(searchRequest.getItemIds())
.productIds(searchRequest.getProductIds())
.skuCodes(searchRequest.getSkuCodes())
.name(searchRequest.getName())
.from(searchRequest.getFrom())
.to(searchRequest.getTo())
.published(searchRequest.getPublished())
.discountEventType(searchRequest.getDiscountEventType())
.build();
}
//(...)
}
결국 이관할 API가 위치한 인터널 모듈 안에 ~Mapper
형태의 새 클래스를 생성하고, 여기서 DTO -> Command
변환이 이루어지도록 코드를 재작성했다. 수정 및 생성 클래스가 80개 가까이 되는 방대한 작업이었다. 이 과정에서 private로 설정된 빌더의 접근 제한을 해제하는 등 여러 가지 변경사항이 있었다. 실수로 필수 필드를 제외하고 매핑하는 일도 있었다. 이 과정에서 DTO의 생성자/필드 적용 방식에 대해 많은 것들을 배웠다.
생성과 수정에 동일한 DTO를 사용한다고?
이관 작업 도중 흥미로운 사실을 발견했다. 특정 테이블에 값을 생성하거나 수정할 때, 같은 요청 DTO를 사용하고 있었다. 그 이름은 UpsertRequest였다.
public class JPA_기본구현체 {
@Transactional
public <S extends T> S save(S entity) {
if (this.entityInformation.isNew(entity)) {
this.em.persist(entity);
return entity;
} else {
return this.em.merge(entity);
}
}
//(...)
}
사실, upsert라는 개념 자체는 낯설지 않다. 스프링 데이터 JPA 속 save 메소드는 새로운 값일 경우에는 insert를 실행하고, 이미 존재하는 엔티티인 경우에는 merge를 수행한다.
그러나 DTO에서도 같은 방식을 사용하면 문제가 발생한다. 생성을 위한 요청 DTO에서 필요하지 않은 id 필드가 포함되어야 한다. 게다가 해당 API를 사용하는 운영 도구 팀은 id에 어떤 값을 넣어야 하는지 알기 어렵다.
물론 id 필드를 제외하고 null로 처리할 수 있지만, 수정을 위한 요청 DTO에서는 id 값이 필수적이어서 @Valid를 통해 검증해야 한다. 스웨거 문서화를 위해서라도 DTO를 분리를 진행했다. 사실, upsert dto에 대한 궁금증이 생긴 초창기에 답을 찾을 수도 있었을텐데, 일정에 밀려 일단 API를 옮기고 봤었다. 이후 클라이언트에서 해당 id 필드에 대해 질문할 때에서야 dto 분리를 진행했다. 앞으론 이관/생성하는 API에 대해 필드별 설명을 할 수 있도록 준비하고, 나아가 docs에 설명을 추가할 것이다.
fin
만약 처음에 들었던 대로 코드를 그냥 복사-붙여넣기로 해결하는 방식으로 진행했다면, 기존 코드의 이관이 더 빠르게 이루어졌을지도 모른다. 하지만, 리팩토링을 진행하며 설계와 의존성 제거의 중요성을 느낄 수 있었다.
리팩토링 과정에서 팀원들이 꼼꼼하게 코드 리뷰를 해주셔서 감사했고, 중간에 발견된 버그를 핫픽스로 급하게 수정하기도 했다. 많은 고민을 거치며 그만큼 큰 성장을 이룬 소중한 시간이었다.