<개요>

https://icthuman.tistory.com/entry/%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EB%8A%94-%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4-2-%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%9D%84-%EB%8B%B4%EC%9E%90

 

사례로 배워보는 디자인패턴 #2 - 비지니스 로직을 담자

<개요> - 일전에는 간단히 MVC Layer로 조회 API를 만들어 봤습니다. https://icthuman.tistory.com/entry/%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EB%8A%94-%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C..

icthuman.tistory.com

- 신규 서비스를 등록하는 API를 작성하였습니다.

- 비지니스 로직이 담긴 serviceCode라는 필드가 추가 되었습니다.

 

<내용>

- 지난 시간에는 신규 서비스를 생성하였습니다. 

- 이번시간에는 serviceId외에 입력하였던 serviceCode로 조회하는 API를 추가해보겠습니다.

반복되는 소스는 제외하고 살펴보도록 하겠습니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {

	@RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
    public @ResponseBody
    List<DeviceModelDto> findServiceByServiceCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                                          @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException {
        // @ResponseBody means the returned String is the response, not a view name
        // @RequestParam means it is a parameter from the GET or POST request
        ServiceDto serviceDto =  serviceService.findServiceByServiceCode(serviceCode);
        if((serviceDto == null) ||
                loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())
        ){
            throw new ServiceNotFoundException(String.valueOf(serviceCode));
}

기존에 만들었던 getServiceByServiceId는 사용할 수가 없습니다. serviceId를 인자값으로 하고 있었는데 이제는 serviceCode를 사용해야하기 때문입니다.

 

Service Layer에도 작업이 필요합니다.

public ServiceDto findServiceByServiceCode(String serviceCode) throws DataFormatViolationException {

        Pattern codePattern = ValidationPattern.serviceCodePattern;
        Matcher matcher = codePattern.matcher(serviceCode);

        if(!matcher.matches()){
            throw new DataFormatViolationException("Code value should be consist of alphabet lowercase, number and '-', (length is from 2 to 20)");
        }

        ServiceEntity serviceEntity = serviceRepository.findByServiceCode(serviceCode).orElse(new ServiceEntity());
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }

이때 주의해야 할 점은 입력으로 받는 serviceCode에 대해서도 기존과 동일한 검증로직을 적용해주는 것이 좋습니다. 없어도 상관은 없습니다. 그러나 불필요한 요청이 Repository Layer까지 전달될 필요는 없을 것 같습니다. (Repository Layer는 언제나 비용이 가장 비쌉니다.)

기본적으로 MVC는 Layerd Architecture이기 때문입니다.

 

다시 Controller 로 돌아가서 윗 부분을 로직도 간결하게 만들어 보겠습니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {
	
     @RequestMapping(value="/services/{serviceCode}/devices", method= RequestMethod.GET)
    public @ResponseBody
    List<DeviceDto> findServiceByServiceCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                                                 @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException, DataFormatViolationException {
        
        ServiceDto serviceDto = getServiceByServiceCode(serviceCode, loginUserDetails);
		return serviceDto;
    }


	private ServiceDto getServiceByServiceCode(String serviceCode, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceByServiceCode(serviceCode);

        if((loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
            StringUtils.isEmpty(serviceDto.getServiceId())
        ){
            throw new ServiceNotFoundException(String.valueOf(serviceCode));
        }

        return serviceDto;
    }

- Null 처리를 Service Layer에서 해주었기 때문에 Controller Layer에서는 삭제가 가능합니다.

- serviceCode로 조회되는 경우도 재사용을 할 수 있도록 getServiceByServiceCode로 묶어서 private 메소드로 구현하였습니다.

 

기본적인 내용이지만 잠깐 짚고 넘어가야할 부분이 있습니다. 많은 분들이 개발을 할때 습관적으로 메소드의 기본을 public 으로 작성합니다.

왜그럴까요? 일단 다 사용할 수 있게 해주는 것이 편리하기 때문입니다. getter, setter 역시 습관적으로 모든 필드값에 만들어 놓고 시작하는 경우를 많이 봅니다.

 

하지만 이러한 습관은 설계의 기본원칙을 무시하는 위험한 행동입니다. 저는 개인적으로 private을 기본으로 하고 필요한 경우에만 public 메소드를 통해서 열어주는 것을 권장합니다. 메소드와 필드값 모두 동일한 원칙으로 적용합니다.

 

첫번째 시간에 LoginUserDetails내에서 service Id를 외부로 노출하지 않았던 것을 기억하시기 바랍니다. 현재 사용자의 serviceId를 가지고 작업해야 경우가 생긴다면 해당 객체의 method call을 하는 것이 맞습니다. 교과서적인 용어로는 객체간의 Interaction이라고 합니다.

 

최종 작업을 통해서 아래와 같은 코드가 작성되었습니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {

	@RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceById (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);
        return serviceDto;
    }
    
    @RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceByCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByServiceCode(serviceId, loginUserDetails);
        return serviceDto;
    }


	private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }


    private ServiceDto getServiceByServiceCode(String serviceCode, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceByServiceCode(serviceCode);

        if((loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId()) ){
            throw new ServiceNotFoundException(String.valueOf(serviceCode));
        }

        return serviceDto;
    }

똑같은 로직이 반복되는 것이 눈에 보입니다.

serviceId , serviceCode의 차이만 있고 나머지는 거의 유사합니다.

 

호출되는 Service Layer의 메소드명, 인자값만 약간 다른 것을 보니 여전히 통합할 수 있는 부분들이 보입니다.

조회하는 조건값을 serviceId, serviceCode로 나누어서 동작하면 service 호출외의 부분은 정리할 수 있을 것 같습니다.

 

과거에는 이러한 분기조건에서 int, char, boolean을 쓰는 경우가 많았지만 적어도 Java에서는 enum type이라는 좋은 대안이 있습니다.

public enum SearchConditionType {
    ID,CODE;
}
@RestController
@RequestMapping(path="/api")
public class ServiceController {

	@RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceById (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByCondition(SearchConditionType.ID, serviceId, loginUserDetails);
        return serviceDto;
    }
    
    @RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceByCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByCondition(SearchConditionType.CODE, serviceCode, loginUserDetails);
        return serviceDto;
    }


private ServiceDto getServiceByCondition(SearchConditionType searchConditionType, Object condition, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto = null;
        switch (searchConditionType){
            case ID:
                serviceDto = serviceService.findServiceById((Integer)condition);
                break;
            case CODE:
                serviceDto = serviceService.findServiceByServiceCode((String)condition);
                break;
        }
        if(loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(condition.toString());
        }

- 이와 같은 방법을 통해서 Validation Logic 을 하나로 통합하여 재사용할 수 있습니다.

  재사용을 고려하지 않고 ctrl + c, v를 무작정 사용해서 개발할 경우 나중에 수정사항이 발생했을 때의 여파는 생각보다 큽니다!

 매번 수작업으로 find usages를 해서 소스를 고쳐야 하고, 다 고치고 나서도 불안하며, 테스트코드도 모든 케이스 개별로 다시 해야합니다.

 

- enum을 사용할 경우 유효하지 않은 값의 입력을 막을 수 있습니다. 그 외에도 enum의 활용도는 무궁무진합니다.

  완벽한 싱글톤 객체로 사용되기도 하고(effective java), 코드 테이블용도로 사용되기도 합니다.

  제 블로그에 Status 및 Operation 을 담는 객체로 활용한 예제도 있으니 참고하시기 바랍니다.

 

- Object 를 특정 타입으로 캐스팅하는 것은 권장하고 싶지 않은 방법이지만 간단한 예제를 위해서 사용했습니다.

 

- switch문으로 분기문을 쭉 나열하는 것도 좋은 방법은 아닙니다..

 

<정리>

- 비지니스 검증은 되도록 Service Layer에서, Repository Layer는 접근 비용이 비쌉니다.

- 필드값, 메소드의 접근자는 생각하면서 사용합니다.

- ctrl + c, v는 없도록 합니다.

- Java enum은 다용도로 활용이 가능합니다.

 

 

 

지금까지는 어찌보면 간단한 구현이었습니다.

하지만 실제 비지니스는 더욱 복잡합니다.

 

- Join데이터들을 조회할 때 문제점

- 늘어나는 케이스마다 동일 변수 사용시 (e.g. serviceCode, serviceId) 공통화 할 수 있는 부분은?

- 테이블의 종류는 점점 늘어날 것인데, 조회된 값이 유효한지 (e.g. Id가 비어있지는 않은지, 0이 오지는 않는지) 매번 검증할 것인가

- 테이블 객체에서 검증할 것인가, 별도 객체에게 위임할 것인가.

- 만약 primary key의 타입이 달라지는 경우는 어떻게 비교할지

 

등등, 시간이 될때 마다 정리해서 올려보도록 하겠습니다.

<개요>

- 일전에는 간단히 MVC Layer로 조회 API를 만들어 봤습니다.

https://icthuman.tistory.com/entry/%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EB%8A%94-%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4-1-%EA%B8%B0%EB%B3%B8%EC%A0%81%EC%9D%B8-MVC

 

사례로 배워보는 디자인패턴 #1 - 기본적인 MVC

<개요> - 일반적인 Web MVC구조에 따라서 Service 등록/수정/삭제/조회 하는 REST API를 만든다고 가정합니다. <내용> 가장 단순한 건당 조회를 살펴봅니다. Controller 클래스 입니다. @RestController @RequestM..

icthuman.tistory.com

- 오늘은 비지니스 로직 구현 및 Null 처리에 대해서 정리해보겠습니다.

 

 

<내용>

이번에는 신규 등록하는 API를 작업해보도록 하겠습니다.

    @RequestMapping(value="/services", method= RequestMethod.POST)
    public @ResponseBody
    ServiceDto createService (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                              @RequestBody ServiceDto service, HttpServletResponse response) throws DataFormatViolationException, ServiceAlreadyExistsException {

        // serviceCode 중복 체크 수행
        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);

        UserEntity userEntity = new UserEntity();
        userEntity.setUserId(loginUserDetails.getUserId());
        service.setUser(userEntity);

        ServiceDto ret = serviceService.createService(service);
        response.setStatus(HttpServletResponse.SC_CREATED);
        return ret;
    }

- 등록은 일반적으로 POST 방식을 사용하며 멱등성을 보장해야 합니다.

- 등록하기 전에 기존에 같은 id가 존재하는지 중복체크 수행을 합니다.

 

신규등록을 하기 위해서 이전에 만들었던 로직을 재사용할 수 있습니다. (getServiceByServiceId)

 

그런데 이전에 만들었던 부분에서 약간 보완해야 할 부분이 있습니다. 

private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     (serviceDto == null) ||
                loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

이와 같이 Service에서 조회된 값이 Null인지를 반드시 체크해줘야 합니다.

serviceDto가 null 인경우 getServiceId()를 수행할때 NullPointerException이 발생하기 때문입니다.

 

NullPointerException의 경우 다른 Layer로 전파되지 않도록 하는 것이 로직을 간결하게 만드는데 도움이 됩니다.

또한 Controller Layer의 경우 앞에서 말씀드린 것처럼 Web과 연결되는 부분에 대해서만 담당하도록 하는 것이 좋습니다.

그럼 어떻게 하는게 좋을까요?

 

시스템의 특성상 다양한 처리방법이 존재하지만 이번 예제에서는 간단히 Service Layer에서 Optional을 이용해서 구현했습니다.

@Service
public class ServiceService {
    @Autowired
    private ServiceRepository serviceRepository;

	public ServiceDto findServiceById(int id){
        ServiceEntity serviceEntity = serviceRepository.findById(id).orElse(new ServiceEntity());
        return modelMapper.map(serviceEntity, ServiceDto.class);
	}

 Null인 경우 빈 객체를 하나 생성하여 Return 하도록 하였습니다.

 

이렇게 하면 더이상 Controller Layer에서는 Null에 대해서 신경쓰지 않아도 되기 때문에 조금 더 간결해집니다.

private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

 

이제 Service Layer에 생성로직을 구현합니다.

신규 생성시 간단한 비지니스 규칙을 하나 추가해 보겠습니다.

 

비지니스 규칙 : serviceCode는 영문소문자와 숫자, '-' 로만 구성되어야 하며 2 ~ 20글자까지 허용한다. 

public ServiceDto createService(ServiceDto serviceDto) throws DataFormatViolationException {

        Pattern codePattern = ValidationPattern.serviceCodePattern;
        Matcher matcher = codePattern.matcher(serviceDto.getServiceCode());

        if(!matcher.matches()){
            throw new DataFormatViolationException("Code value should be consist of alphabet lowercase, number and '-', (length is from 2 to 20)");
        }

        ServiceEntity serviceEntity =modelMapper.map(serviceDto, ServiceEntity.class);
        serviceRepository.save(serviceEntity);
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }
}

이와 같이 로직을 구성하고 값을 저장합니다. Repository Layer에서는 serviceCode 컬럼 값에 대해서 신경쓰지 않고 데이터 저장에만 집중할 수 있습니다.

 

UI에서도 이와 같은 validation을 동일하게 구현할 수 있지만 보다 시스템을 튼튼하게 만들기 위해서는 Service Layer에서 반드시 체크해야 합니다. 나중에 다른 비지니스 프로세스를 개발할 때 createService를 재사용할 수도 있기 때문입니다.

 

ServiceCode를 검증하는 중 발생하는 오류에 대해서는 별도 Exception으로 처리하였습니다.

되도록이면 Raw Exception을 사용하는 것은 지양합니다. 그리고 어떤 Exception을 어느 Layer까지 전파시킬 것인가에 대해서도 사전에 정의하는 것이 좋습니다.

 

아키텍처 레벨의 디자인패턴에 대해서는 나중에 추가로 정리하겠습니다.

 

 

<정리>

- 1장과 동일한 포인트입니다. 각 Layer는 역할에 맞는 기능이 구현되어야 합니다.

- 로직은 Controller Layer에 담지 않습니다.

- Null 처리는 표준화 합니다.

- Exception 은 상세하게 사용합니다.

 

 

 

 

<개요>

- 일반적인 Web MVC구조에 따라서 Service 등록/수정/삭제/조회 하는 REST API를 만든다고 가정합니다.

 

 

<내용>

가장 단순한 건당 조회를 살펴봅니다.

Controller 클래스 입니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {

    
    @RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findService (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        // @ResponseBody means the returned String is the response, not a view name
        // @RequestParam means it is a parameter from the GET or POST request

        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);
        return serviceDto;
    }

    private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     (serviceDto == null) ||
                loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

- LoginUserDetails 의 경우 로그인한 사용자의 정보를 저장합니다.
- serviceId를 이용하여 데이터를 조회하고 결과값을 간단하게 검증하는 로직입니다.

- 결과값이 다음 중 하나와 같으면 현재 Service가 존재하지 않는 것으로 판단합니다.

 1. 객체가 null 인 경우

 2. ID필드의 값이 없는 경우

 3. 값이 존재하나 로그인한 사용자의 정보에 해당하지 않는(본인의 서비스가 아닌 경우) 경우

 

이와 같이 Service 를 조회하고 값을 검증하는 로직은 조회 이외에 등록/수정에서도 필요하기 때문에 별도 메소드를 작성하여 재사용하였습니다.

 

그리고 현재 ServiceId와 로그인 사용자의 ServiceId를 비교하는 로직의 경우 Controller에서 구현하는 것보다는 정보은닉화와 설계원칙에 적합해 보여서 LoginUserDetails 내부에 구현하였습니다.

 

좀더 자세히 살펴보겠습니다.

public class LoginUserDetails extends User {

    private static String ROLE_ADMIN = "ROLE_ADMIN";

    private Integer userId;

    private Collection<Integer> services;

    public LoginUserDetails(Integer userId,
                            String password,
                            String userName,
                            Collection<? extends GrantedAuthority> authorities,
                            Collection<Integer> services){
        super(userName, password, authorities);
        this.services = services;
        this.userId = userId;
    
    
    public boolean checkNotAvailableService(Integer serviceId){

        if(this.getAuthorities().contains(new SimpleGrantedAuthority(ROLE_ADMIN)) ){
            return false;
        }

        for(Integer eachService : this.services){
            if(eachService.equals(serviceId)){
                return false;
            }
        }
        return true;
    }

이와 같이 구현하면 현재 사용자의 serviceId등은 외부로 노출시키지 않아도 되며 (getter를 작성하지 않아도 됩니다.)

값의 검증이 필요한 모듈은 LoginUserDetails 객체에 요청하기만 하면 됩니다.

 

Controller layer의 경우 web과 바로 연결되어 있는 부분들을 담당하기 때문에 login관련정보나 인자값을 주로 처리하며 비지니스 로직은 Service Layer에 존재하게 됩니다.

 

 

다음으로 Service Layer를 살펴보겠습니다.

@Service
public class ServiceService {
    @Autowired
    private ServiceRepository serviceRepository;

    @Autowired
    private ModelMapper modelMapper;

    public ServiceDto findServiceById(int id){
        ServiceEntity serviceEntity = serviceRepository.findById(id);
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }

지금은 아무 로직이 없기 때문에 단순히 Repository 로부터 값을 조회하여 객체의 값을 매핑만 합니다.

 

 현재 프로젝트에서는 Spring JPA를 사용하여 시스템을 구축중인데, Entity클래스의 변경은 되도록 줄이는 것이 좋습니다. 만약 Entity 클래스 하나의 유형으로 화면 - 서비스 - 데이터를 모두 처리하게 될 경우 객체지향에서 말하는 대표적인 anti pattern이 될 수 있기 때문에 별도 DTO 클래스를 사용합니다.

 

@Data
@Getter
@Setter
public class ServiceDto {

    private Integer serviceId;

    private String serviceName;

    private String serviceCode;

    private ServiceType serviceType;

    private String description;

    private LocalDateTime creationDateTime;

    private LocalDateTime modificationDateTime;

    private Integer userId;

    private UserEntity user;

}

 

<정리>

- Layer는 일반적으로 자신과 연결되어 있는 부분에 대해서만 인터페이스 하는 것이 원칙입니다.

- Controller Layer에서는 실제 데이터 베이스 저장에 대해서 알 필요가 없으며

- Service Layer에서는 Web기술에 대해서 알 필요가 없고

- Repository Layer는 온전히 데이터의 저장만을 담당합니다.

 

다음시간에는 조금 더 자세한 케이스를 다뤄보겠습니다. 

+ Recent posts