맨땅에 헤딩하는 개바른자

CRUD 공통로직 만들기 본문

개발 TIP

CRUD 공통로직 만들기

앵낄낄 2022. 5. 10. 20:49
반응형

가끔은 반복된 소스코드를 작성할 때 여러분은 귀차니즘을 느끼고 있지 않으신가요?

기본적이고 간단한 CURD를 작성할 때 공통된 로직이 있으면 어떨까하는 생각에 실제 업무에서 적용가능한 코드를 만들어보았습니다.

이러 비슷한 코드를 이전회사에서 본적이 있었는데 그 때 너무 획기적으로 다가와서 쭉사용하고 있는 기술 입니다.

어찌보면 과하다고 생각될 수 있지만 한번구성을 만들고 사용하다보면 엄청 편하다는 것을 느낄 때가 많습니다.

그럼 바로 코드 설명으로 넘어가겠습니다.

아래 코드들은 일부 항목만 인용하였으며 인터페이스나, 상속객체내의 메소드의 상세 코드들은 생략하도록 하겠습니다.

(필요하시다면 각자 구성이나 구현방식이 다를 수 있기에...)

[CRUD 공통로직 인터페이스]

public interface CrudHandler<ID, Dto, Request> {
    Dto save(Dto dto);

    Dto findById(Dto dto);

    Page<Dto> findAll(Request request);

    Dto putById(Dto dto);

    void deleteById(ID id);
}

공통객체를 만들기 전 가장 기본이되는 저장, 단건조회, 다건조회, 수정, 삭제의 메소드를 구현하게끔 인터페이스를 만들었습니다.

 

[CRUD 공통로직]

CrudHandler 인터페이스를 구현한 본 코드들이 있는 코드영역 입니다.

@Slf4j
public abstract class SimpleCrudHandler<
            ID extends Serializable,
            Request extends AbstractRequest<Dto, Entity>,
            Dto extends AbstractDto<ID, Dto, Entity>,
            Entity extends Audit,
            BaseRepository extends SimpleBaseRepository<Entity, ID>
        >
        implements CrudHandler<ID, Dto, Request> {

    @Autowired
    protected BaseRepository baseRepository;

    @Autowired
    protected HntObjectMapper hntObjectMapper;

    public Dto save(final Dto dto) {
        Entity toEntity = Optional.of(dto)
                .map(preProcessDto ->
                        Optional.ofNullable(this.savePreProcess(preProcessDto)) // 전처리
                                .orElse(preProcessDto)
                )
                .map(Dto::toEntity)
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.SAVE_PRE_PROCESS_ERROR));

        Entity entity;
        try {
            entity = baseRepository.save(toEntity);
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.SAVE_ERROR, e.getLocalizedMessage());
        }

        return Optional.of(entity)
                .map(dto::of)
                .map(postProcessDto ->
                        Optional.ofNullable(this.savePostProcess(postProcessDto)) // 후처리
                                .orElse(postProcessDto)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.SAVE_POST_PROCESS_ERROR));
    }

    @Override
    public Dto findById(final Dto dto) {
        ID preProcessId = Optional.ofNullable(dto.getId())
                .map(getId ->
                        Optional.ofNullable(this.getByIdPreProcess(getId)) // 전처리
                                .orElse(getId)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_BY_ID_PRE_PROCESS_ERROR));

        Entity entity;
        try {
            entity = baseRepository.findById(preProcessId)
                    .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_BY_ID_ERROR));
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.FIND_BY_ID_ERROR, e.getMessage());
        }

        return Optional.of(entity)
                .map(dto::of)
                .map(postProcessDto ->
                        Optional.ofNullable(this.getByIdPostProcess(postProcessDto)) // 후처리
                                .orElse(postProcessDto)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_BY_ID_POST_PROCESS_ERROR));
    }

    @Override
    public Page<Dto> findAll(final Request request) {
        Request req = Optional.of(request)
                .map(preProcessRequest ->
                        Optional.ofNullable(this.findAllPreProcess(preProcessRequest)) // 전처리
                                .orElse(preProcessRequest)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_ALL_PRE_PROCESS_ERROR));

        Page<Dto> pageDto;
        try {
            Pageable pageable = req.getPageable();
            Specification<Entity> specification = req.forSearch();
            Page<Entity> page = baseRepository.findAll(specification, pageable);
            pageDto = new PageImpl<>(
                page.stream().map(request.newInstanceDto()::of).collect(Collectors.toList()),
                pageable,
                page.getTotalElements()
            );
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.GET_PAGE_ERROR, e.getMessage());
        }

        return Optional.of(pageDto)
                .map(postProcessDto ->
                        Optional.ofNullable(this.findAllPostProcess(postProcessDto)) // 후처리
                                .orElse(postProcessDto)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_ALL_POST_PROCESS_ERROR));
    }

    @Override
    public Dto putById(final Dto dto) {
        Entity toEntity = Optional.ofNullable(dto.getId())
                .flatMap(baseRepository::findById)
                .map(findEntity -> {
                    dto.copy(findEntity); // 변경 할 항목 복사
                    findEntity.setUpdatedAt(LocalDateTime.now()); // 변경 날짜 현재 시간 SET
                    return Optional.ofNullable(this.putByIdPreProcess(findEntity, dto)) // 전처리
                            .orElse(findEntity);
                })
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.PUT_BY_ID_PRE_PROCESS_ERROR));

        Entity entity;
        try {
            entity = baseRepository.save(toEntity);
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.PUT_BY_ID_ERROR, e.getMessage());
        }

        return Optional.of(entity)
                .map(dto::of)
                .map(postProcessDto ->
                        Optional.ofNullable(this.putByIdPostProcess(postProcessDto)) // 후처리
                                .orElse(postProcessDto)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.PUT_BY_ID_POST_PROCESS_ERROR));
    }

    @Override
    public void deleteById(final ID id) {
        this.deleteByIdPreProcess(id); // 전처리
        try {
            baseRepository.deleteById(id);
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.DELETE_BY_ID_ERROR, e.getMessage());
        }
        this.deleteByIdPostProcess(id); // 후처리
    }

    /**
     * This is Method Optional.
     * 상위 메소드들의 전후 처리를 위한 메소드 (선택사항)
     * 전처리(preProcess)
     * 후처리(postProcess)
     *
     * 사용 예)
     * - CRUD 전후 값 변경이 필요 할 때
     * - 트리거 형태로 다른 로직 처리가 필요할 때(API, ES, REDIS..)
     */
    protected Dto savePreProcess(Dto dto) {return null;} // 등록 전처리
    protected Dto savePostProcess(Dto dto) {return null;} // 등록 후처리

    protected ID getByIdPreProcess(ID dto) {return null;} // 상세조회 전처리
    protected Dto getByIdPostProcess(Dto dto) {return null;} // 상세조회 후처리

    protected Request findAllPreProcess(Request request) {return null;} // 페이지 조회 전처리
    protected Page<Dto> findAllPostProcess(Page<Dto> dto) {return null;} // 페이지 조회 후처리

    protected Entity putByIdPreProcess(Entity findEntity, Dto dto) {return null;} // 업데이트 전처리
    protected Dto putByIdPostProcess(Dto dto) {return null;} // 업데이트 후처리

    protected void deleteByIdPreProcess(ID id) {} // 삭제 전처리
    protected void deleteByIdPostProcess(ID id) {} // 삭제 후처리
}

Type사용이 많은 코드라서 다소 복잡해 볼 일 수 있는코드 입니다.

하나하나 설명을 들어가보겠습니다.

SimpleCrudHandler<> 제네릭안에 5개의 타입이 선언되어있습니다.

  • ID extends Serializable
    • delete 처리 시 보통 ID항목만 받아서 deleteById형태로 삭제하기위한 타입입니다.
  • Request extends AbstractRequest<Dto, Entity>
    • DTO성향과 다소 다른 용도의 페이징조회 용 Type 입니다.
    • 해당 타입은 페이징조회용도이기에 AbstractRequest 상속객체엔 page관련 된 sort, size, page 등의 변수가 존재하고 page처리에 필요한 메소드가 존재 합니다.
      • public Pageable getPageable()
      • public Sort extractSort()
  • Dto extends AbstractDto<ID, Dto, Entity>
    • 저장, 수정에 사용되는 Dto용 객체 Type 입니다.
    • AbstractDto 상속객체에는 ID를 사용할 getd() 메소드가 있습니다.
    • 추가로 dto > entity, entity > dto를 변환해줄 수 있는 메소드가 있습니다.
  • Entity extends Audit
    • DB에 사용되는 Entity객체 Type 입니다.
  • BaseRepository extends SimpleBaseRepository<Entity, ID>
    • DB Repositoey 관련 Type 입니다.
    • 여기선 JPA로 db처리를 하고 있어서 SimpleBaseRepository 객체를 상속받아서 사용하는 것으로 하였습니다.
    • JpaRepository를 사용해도되지만 저 같은 경우 Specification기능을 사용하고 싶어서 SimpleBaseRepository 을 상속하였습니다.

흠.. 상속과 인터페이스 왜 저렇게 사용했지라고 할 수 있을 겁니다.

공통로직은 다양한 메소드 기능들이 필요할 수 있습니다. 해당 메소드 기능들을 공통에서 사용하려면 본 클래스의 기능은 호출이 안됩니다 상속했을나 인터페이스를 구현했더나한 객체가 어떤 Type이든 부모의 기능을 호출 할 수 있는 원칙을 이용한 것입니다.

(제가 말주변이 없어서 설명이 이해가 안되실수도있는데... 그래도 노력해봅시다!!)

쉽게 말해서 공통 CRUD 객체를 이용하려면 재료가 필요하고, 어떤 재료들이 필요한지를 선언해둔 것입니다.

즉.. 재료들이 없다면 사용 못하겠죠?

그래서 이 코드는 제가 담당하고 있는 프로젝트에서만 사용하고 있습니다. 처음 보았을 때 거부감이 생길 수 있는 부분이 존재하더라고요

다음으로

각 메소드를 보면 try catch부분도 있고, 무언가 공통적으로 선언된 코드들이 있습니다. 대부분 비슷한 원리이긴한데 좀더 특한한 findAll 부분으로 설명드리도록 하겠습니다.

@Override
    public Page<Dto> findAll(final Request request) {
        1) 조회 요청객체 프로세스 처리
				Request req = Optional.of(request)
                .map(preProcessRequest ->
                        Optional.ofNullable(this.findAllPreProcess(preProcessRequest)) // 전처리
                                .orElse(preProcessRequest)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_ALL_PRE_PROCESS_ERROR));

				2) 실제 DB 조회 구간
        Page<Dto> pageDto;
        try {
            Pageable pageable = req.getPageable();
            Specification<Entity> specification = req.forSearch();
            Page<Entity> page = baseRepository.findAll(specification, pageable);
            pageDto = new PageImpl<>(
                page.stream().map(request.newInstanceDto()::of).collect(Collectors.toList()),
                pageable,
                page.getTotalElements()
            );
        } catch (Exception e) {
            throw new HntRuntimeException(OtisErrorCode.GET_PAGE_ERROR, e.getMessage());
        }

				3) 조회 된 객체 프로세스 처리
        return Optional.of(pageDto)
                .map(postProcessDto ->
                        Optional.ofNullable(this.findAllPostProcess(postProcessDto)) // 후처리
                                .orElse(postProcessDto)
                )
                .orElseThrow(() -> new HntRuntimeException(OtisErrorCode.FIND_ALL_POST_PROCESS_ERROR));
    }

코드 내에 순번을 작성해둔 순서데로 설명해보겠습니다.

1)전처리 구간

  • DB처리 전 전달받은 객체에 대해서 무언가 처리를 할 때 필요한 구간입니다.
  • findAllPreProcess 메소드에서 무언가 처리되면서 null이면 기존객체 그대로 사용하고 null이 아니면 메소드에서 처리 된결과를 사용하게 됩니다.

2) DB처리 구간

  • request객체엔 위에 말씀드린 getPageable 페이징 정보를 얻어오는 메소드가 있습니다. 해당 메소드를 통해서 Pageable 객체를 생성합니다.
  • forSearch() 메소드는 request객체에 선언된 기능입니다. Request내에 선언 된 변수의 값에 따라 Specification정보를 셋팅하는 로직으로 보시면 되겠습니다.
  • repository.findAll()에 사용 될 pageable과 specification재료를 사용하여 쿼리질의를 날립니다.
  • 질의 된 결과는 PageImpl로 생성합니다.
    • 이과정에서 Entity > Dto로 변환해주는 newInstanceDto 메소드를 사용하였습니다. (그냥 이런 용도라고만 생각해주시면 될듯합니다. 상세 로직은 알아서 구현하시면 될듯 합니다.)

3) 후처리 구간

  • pageDto를 가지고 리턴하기전 어떠한 처리과정이 필요할 때 걸치는 구간입니다.
  • findAllPostProcess() 메소드에서 처리 된 결과 값이 있으면 그 값을 사용하고 null인경우 원래의 값을 리턴하게 됩니다.

메소드는 이러한 전처리, 본처리, 후처리 개념의 구간들이 존재합니다.

그리고 전처리, 후처리 구간에서 null이면 원래 값 값이 있으면 처리 된 값을 사용한다고 설명드렸었는데요 어떻게 되는건지 궁금해 하실 수 있습니다.

해당 부분은 아래 정의 된 메소드들에 의해서 처리가 됩니다. 기본적으로는 모두 null을 리턴하고 있습니다.

/**
     * This is Method Optional.
     * 상위 메소드들의 전후 처리를 위한 메소드 (선택사항)
     * 전처리(preProcess)
     * 후처리(postProcess)
     *
     * 사용 예)
     * - CRUD 전후 값 변경이 필요 할 때
     * - 트리거 형태로 다른 로직 처리가 필요할 때(API, ES, REDIS..)
     */
    protected Dto savePreProcess(Dto dto) {return null;} // 등록 전처리
    protected Dto savePostProcess(Dto dto) {return null;} // 등록 후처리

    protected ID getByIdPreProcess(ID dto) {return null;} // 상세조회 전처리
    protected Dto getByIdPostProcess(Dto dto) {return null;} // 상세조회 후처리

    protected Request findAllPreProcess(Request request) {return null;} // 페이지 조회 전처리
    protected Page<Dto> findAllPostProcess(Page<Dto> dto) {return null;} // 페이지 조회 후처리

    protected Entity putByIdPreProcess(Entity findEntity, Dto dto) {return null;} // 업데이트 전처리
    protected Dto putByIdPostProcess(Dto dto) {return null;} // 업데이트 후처리

    protected void deleteByIdPreProcess(ID id) {} // 삭제 전처리
    protected void deleteByIdPostProcess(ID id) {} // 삭제 후처리

null을 리턴하기 때문에 현제 상태로는 저장, 수정, 조회, 삭제에서 전처리/후처리는 의미 없이 지나가게 된 로직입니다.

그러면 값이 리턴되는 케이스는 어떨때 떨어지는 지 알아보겠습니다. SimpleCrudHandler 을 상속받아서 사용하는 클래스에 위 protected로 선언 된 메소드를 @Override 하여사용하게 되면 해당 로직이 타게 됩니다.

즉 저장, 수정, 조회, 삭제 시 전후처리가 필요할 때 메소드 네이밍에 맞게 오버라이드하여 구현하면 됩니다.

아직은 뭔가 잘 이해가 안되실 수 있습니다.

실전 코드를 통해서 사용법을 확인 해 보겠습니다.

 

[Handler 클래스 생성]

SimpleCrudHandler을 상속받은 Handler 클래스 입니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class SampleHandler extends SimpleCrudHandler<Long, SampleRequest, SampleDto, Sample, SampleRepository> {

		private final SomeRepository someRepository;

		@Override
	  protected Request findAllPreProcess(Request request) {
				return request.setSome("dumy");
		} 

		public Sample some(SampleDto sampleDto) {
				Sample saved = super.save(sampleDto);
				saved.setSome("aa");
				return saved;
		}
}

Sample객체에 관련 된 재료 5가지를 제네릭 구간에 선언하였습니다.

해당 재료들의 특성들은 다 알고 계실거라고 가정하여 넘어가겠습니다.

  • findAllPreProcess 부분은 SimpleCrudHandler에 선언 된 protected 메소드 입니다.
    • 보시면 SimpleCrudHandler에 재료들이 다 존재하기때문에 super를 써서 부모의 기능을 사용할 수 가 있습니다.
    • 그밖에도 부모에 구현 된 모든 기능을 활용 할 수가 있을 겁니다.
  • 해당 메소드를 오버라이드하여 구현하게되면 SimpleCrudHandler클래스의 findAll메소드가 실행 될 때 트리거 처럼 수행되게 됩니다.
  • public Sample some(SampleDto sampleDto) 이것은 기본적인 CRUD가아닌 다른 작업의 기능이 필요할 때 handler에서 구현 된 기본 이외의 메소드 입니다.
  • 다른 Entity 모델 성향의 DB처리가 필요한 경우 관련 Repository를 사용 할 수 있습니다.
    • private final SomeRepository someRepository;

 

[service 클래스에서 handler 사용]

@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class SampleServiceImpl implements SampleService {

		private final SampleHandler sampleHandler;

		@Override
		public Sample some(SampleDto sampleDto) {
				return sampleHandler.save(sampleDto);
		}
}

service에서는 handler를 상속받아서 사용합니다.

다른 Entity 모델 성향의 Handler도 주입받아서 사용할 수 있을 것입니다.

이 구성이 제일 좋은 점은 service가 service를 호출하는 구성을 피할 수 있다는 것입니다. 개발을 처음 배울 때 다른 service에서 이미 잘 만들어주는 기능이 있어서 serivce주입 받아서 사용하였는데 서버 기동할 때 가끔 실패가 나오는 현상을 겪어본적이 있습니다.

경험이 있으신 분들은 아실테지만 순환참조란 골치아픈 증상입니다. Order라는 기능을써서 스프링 빈에 등록 될 때 순환참조를 피할 순 있지만 이런 것들이 하나둘 많아지게되면 관리가 어려워 질 수 있습니다.

암튼 좀더 강력한 기능들을 설명해보겠습니다.

  • Dto < > Entity 변환의 코드를 직접 선언 할 필요가 없습니다.
  • 자원을 주입받을 때 Handler만 주입받고 Repository는 Handler에 위임되어있기 때문에 코드가 간략하게 됩니다.
  • 순환참조 문제를 피할 수 있습니다.
  • Handler들은 기본적으로 SimpleCrudHandle 을 상속을 했을 뿐인데 별도로 구현없이 save, update, findById, findAll, deleteById의 기본 메소드를 사용할 수가 있습니다.
  • 여러사람들이 코드 작성 시 통일성을 줄 수 있습니다.
  • 공통CRUD의 로직만 검증되면 각 Handler의 CRUD를 별도로 검증할 TEST작성이 필요 없습니다.
  • Test작성 시 단일책임원칙을 준수했기때문에 다양한 Mock을 신경 쓸 필요가 없습니다.

 

어떤가요? 아직은 잘 모르겠다는 분위기가 많을 수 있습니다.

SimpleCrudHandler 를 만들때 각 재료를 만드는 과정이 복잡할 수 있습니다 하지만 5가지의 재료들은 모두 필요하기 때문에 이미 만들어진 재료들이라고 볼 수 있습니다.

그렇기 때문에 기초기반만 잘 다져저 있다면 우리가 할 것은 Handler에 SimpleCrudHandler을 상속하고 사용하기만 하면됩니다.

작업 속도가 쑥쑥 올라가게 됩니다.

그럼 글은 이만 마무리하고 좀더 좋은 의견이 있으시면 댓글로 남겨주시면 큰 도움이 될 거 같습니다.

글을 끝까지 읽어주셔서 감사합니다.

반응형