업무요청 기능 리팩토링
업무요청 기능 리팩토링
배경
업무요청은 이지태스크의 핵심 서비스인 ‘온라인 업무보조’를 사용하기 위한 첫 단계로 고객의 요구사항을 다루는 중요한 로직입니다.
그러나 그 중요도와 별개로 상당한 기술부채가 쌓여 있어 서비스를 개선하기 어려웠으며 작은 변경도 장애로 이어질 수 있는 상황이었습니다.
마침 최근 업무요청 관련 기능추가 프로젝트에 참여한 것을 계기로 짬을 내어 리팩토링을 신규개발과 병행했습니다.
비록 가시적인 비즈니스 임팩트를 보일 수 있는 작업은 아니었지만 묵묵히 리팩토링을 진행한 결과 기술부채를 상당 부분 해소하는 성과를 얻었습니다.
기술부채 및 리팩토링 상세
난잡한 코드를 통일성 있게 간소화
- 문제
- 단기업무와 정기업무의 요청은 공통적으로 20여가지의 로직을 동일한 순서로 수행합니다
- 예시 : 업무요청서 저장 , 포인트 사용, 채팅방 생성, 업무제안 생성 등
- 전해 듣기로는, 과거에 단기업무가 먼저 구현 되었고 정기업무는 그 후에 퀄리티를 고려하지 못하고 단기업무 코드에 급하게 덧붙이는 형태로 개발되었다고 합니다. 기술부채가 체계적으로 관리되지 못했던 탓에 비즈니스 로직이 각종 클래스에 무분별하게 구현되어 있어 응집성과 가독성이 매우 떨어지는 상태였습니다.
- 단기업무와 정기업무의 요청은 공통적으로 20여가지의 로직을 동일한 순서로 수행합니다
- 개선
- request dto 통일성 있게 간소화
- 저는 request dto 또한 클라이언트 - 서버 간의 인터페이스 역할을 하는 중요한 요소라고 생각합니다.
- request dto의 역할을 간결하고 명확하게 드러내기 위해 단기, 정기업무가 공통으로 사용하는 필드를 모아 단일한 클래스로 생성하고 정기업무에서만 사용하는 필드는 별도로 다루도록 request dto를 구현했습니다
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
//공통으로 사용하는 클래스 public class 공통_클래스 { private String 업무요청_제목; private String 업무요청_상세; . . . } public class 단기업무_요청_dto { private 공통_클래스 공통클래스; . . . } public class 정기업무_요청_dto { private 공통_클래스 공통클래스; private LocalDate 종료일; private List<DayOfTheWeekType> 업무 요일 . . }
- request dto 통일성 있게 간소화
공통으로 사용하는 로직을 파사드 패턴으로 묶기
- 상술한대로 단기업무와 정기업무 요청은 공통적으로 20여가지의 서브 로직을 동일한 순서로 수행합니다
- 해당 특징을 더 유연하게 반영하기 위해 아래의 3가지 디자인패턴을 고민했고 결과적으로 퍼사드 패턴만 적용했습니다.
- 채택한 디자인 패턴
- 퍼사드 패턴
- 복잡한 서브시스템을 단순한 인터페이스로 제공하기 위해 채택했습니다
- 컨트롤러는 퍼사드 클래스만 호출하고 퍼사드 클래스가 일련의 로직을 호출하도록 구현하였습니다.
- 퍼사드 패턴
- 기각한 디자인 패턴
- 전략 패턴
- 업무요청의 타입이 단기와 정기 외에 더 이상 늘어나지 않을 것으로 판단하여 기각했습니다.
- 템플릿 메소드 패턴
- 템플릿 메소드 패턴을 사용하려면 서브 클래스에서 상세 동작을 구현해야 합니다.
- 그러나 단기, 정기업무 모두 거의 동일한 상세 동작을 하므로 굳이 각 서브 클래스를 구현할 메리트가 없어 기각했습니다.
- 전략 패턴
- 공통 로직을 private method로 묶기
- 특별한 구조는 아니지만 간결한 코드를 위해 공통 로직을 private method로 묶고 flag 변수로 구분하도록 하였습니다.
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
public class 업무요청 { public void 단기_업무요청_생성(){ private boolean isRecurring = false; this.업무요청서_저장(isRecurring); this.포인트_사용(isRecurring); . . . } public void 정기_업무요청_생성(){ private boolean isRecurring = true; this.업무요청서_저장(isRecurring); this.포인트_사용(isRecurring); . . . } private void 업무요청서_저장(boolean isRecurring){ //업무요청서 저장 } private void 포인트_사용(boolean isRecurring){ //포인트 사용 } }
- 채택한 디자인 패턴
데이터 정합성을 위한 트랜잭션 처리
- 문제
- 정기업무에 사용되는 엔티티는 크게 1)’업무요청 내용’ 엔티티 2)’정기업무 스케쥴’ 엔티티, 2가지가 있습니다. 2개의 엔티티는 정합성을 위해 반드시 함께 원자적으로 처리되어야 합니다.
- 기존의 정기업무 요청 서비스는 ’업무요청 내용’ 엔티티와 ’정기업무 스케쥴’ 엔티티를 생성하는 API가 분리되어 있고 이를 순차적으로 호출하는 형태였습니다.
- 위의 형태는 ’업무요청 내용’ 엔티티 API 성공 후 ’정기업무 스케쥴’ 엔티티 API가 실패할 경우 정합성이 깨지는 심각한 문제점을 가지고 있습니다
- 해결
- 단일 API 내에서 ’업무요청 내용’ 엔티티와 ’정기업무 스케쥴’ 엔티티 생성을 처리하도록 하고 트랜잭션으로 묶어서 처리했습니다
리치 도메인 모델
- 레거시 코드는 각종 비즈니스 로직이 service layer에 흩어져 있었고 중복되는 코드도 많았습니다.
- 코드의 응집성을 높이기 위해 도메인 객체에 비즈니스 로직을 옮기고 service layer는 도메인 객체의 메소드를 호출하는 방식으로 수정했습니다.
RESTful API 원칙 준수
- 레거시 코드는 하나의 API에 flag 값을 이용해서 POST와 PATCH 역할을 둘 다 수행하거나
- ‘임시저장’과 ‘업무요청서’ 엔티티는 아예 다른 자원임에도 동일한 API에서 처리하는 등 Restful하지 않은 구조였습니다.
- 이는 직관적이지 않고 유지보수도 어려운 구조이므로 RESTful 원칙에 맞게 API를 분리했습니다.
스윔 레인 다이어그램
- 업무 요청 후 포인트 정산, 매칭 성공 or 실패, 그에 따른 알림톡 등 다양한 형태의 후속작업이 발생합니다.
- 후속작업은 고객 뿐만 아니라 여러 명의 프리랜서가 개입하므로 정책 상 여러 case가 복잡하게 얽혀있습니다.
- 스윔 레인 다이어그렘을 작성하여 후속작업의 다양한 case를 명확하게 시각화했습니다.
모듈 의존성을 고려하여 임시저장 내용을 문자열로 직렬화
문제
- 임시저장 엔티티는 Redis, 업무요청 엔티티는 RDB에 저장하고 있습니다..
- 업무요청 엔티티에 새로운 필드가 추가될 때 마다 임시저장 엔티티에도 필드를 추가해줘야 하는 상황이었습니다.
- 업무요청과 임시저장 둘 다 동일한 필드를 가지고 있어야 하나 기존 구조 상 Redis 모듈과 RDB 모듈이 서로 분리되어 있어 서로 클래스를 공유할 수가 없기 때문입니다
- 위 제약조건 때문에 업무요청 엔티티를 임시저장 엔티티로 변환해주는 40줄이 넘는 끔찍한 Builder 코드가 존재했습니다.
*예시 : 실제 필드 이름은 보안을 위해 알파벳으로 대체했습니다
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
public static 임시저장_엔티티 create_임시저장_엔티티() { return 임시저장_엔티티.builder() .a(this.getA()) .b(this.getB()) .c(this.getC()) .d(this.getD()) .e(this.getE()) .f(this.getF()) .g(this.getG()) .h(this.getH()) .i(this.getI()) .j(this.getJ()) .k(this.getK()) .l(this.getL()) .m(this.getM()) .n(this.getN()) .o(this.getO()) .p(this.getP()) .q(this.getQ()) .r(this.getR()) .s(this.getS()) .t(this.getT().stream() .map(x -> 임시저장_엔티티.A.builder() .b(임시저장_엔티티.B.builder() .c(x.getC().getC()) .d(x.getC().getD()) .e(x.getC().getE()) .f(x.getC().getF()) .g(x.getC().getG()) .build()) .h(x.getH().stream() .map(y -> this.B.builder() .c(y.getC()) .d(y.getD()) .e(y.getE()) .f(y.getF()) .g(y.getG()) .build()) .collect(Collectors.toList())) .build()) .collect(Collectors.toList())) .u(this.getU()) .v(LocalDateTime.now()) .w(this.getW()) .x(this.getX()) .y(this.getY()) .z(Optional.ofNullable(this.getZ()) .build(); }
해결
- 임시저장 엔티티는 문자열 필드를 가지도록 구현했습니다. 그리고 임시저장 시 업무요청 클래스를 문자열로 직렬화하여 임시저장 엔티티의 문자열 필드에 할당하도록 구현했습니다.
- 덕분에 업무요청 엔티티가 변경되더라도 임시저장 엔티티는 변경할 필요가 없어졌습니다.
예시
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
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @RedisHash(value = "임시저장_엔티티", timeToLive = N) public class 임시저장_엔티티 { @Id private String id; private String 직렬화된_업무요청_엔티티; private LocalDateTime createdAt; private LocalDateTime updatedAt; } @Getter @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class 업무요청_엔티티 { @Id private String id; . . . public String serialize(업무요청_엔티티 업무요청엔티티) { try { return mapper.writeValueAsString(업무요청엔티티); } catch (Exception e) { throw new RuntimeException("직렬화 실패", e); } } public static 업무요청_엔티티 deserialize(String 직렬화된_업무요청_엔티티) { try { return mapper.readValue(직렬화된_업무요청_엔티티, 업무요청_엔티티.class); } catch (Exception e) { throw new RuntimeException("역직렬화 실패", e); } } }
기타
- 주요 DB 테이블에 선제적으로 인덱스 추가
- 모든 Controller, DTO에 swagger 명시
- 유저 pk를 얻기 위해 Spring security의 SecurityContextHolder를 무분별하게 Service layer에서 호출하지 않고 컨트롤러 Layer에서 파라미터로 받도록 수정
- 추후 쉽게 테스트코드를 작성하기 위함
결과
이번 개선으로 업무요청 기능이 간결한 코드와 더불어 스웨거, 플로우 차트 덕분에 누구나 쉽게 이해하고 변경할 수 있는 로직이 되었습니다.
리팩토링은 당장 극적인 임팩트를 낼 수 없다는 이유로 등한시 되는 경우도 있습니다. 그러나 저는 체계적 기술부채 관리의 관점에서 반드시 리팩토링이 꾸준히 진행되어야 한다고 생각합니다.
앞으로도 꾸준히 점진적 리팩토링을 병행하고자 합니다.
향후 과제
- 테스트 코드를 추가하여 서비스의 안정성을 높일 예정입니다
- CI 파이프라인에 자동화된 테스트 코드 실행을 적용할 예정입니다.
This post is licensed under CC BY 4.0 by the author.