반응형

클라이언트가 보낸 정보를 매핑한 Request 객체(DTO).

이 객체는 @Valid 애너테이션을 비롯해 간단하게 검증할 수 있는 방법이 많습니다.

 

그런데 Reqeust의 검증 책임은 어디까지일까요?

 


 

1) Request는 비즈니스 로직 외 간단한 검증만 한다.

Layered architecture를 기준으로, Request의 위치는 Presentation layer입니다.

더 자세하게는 Client(View)와 Controller 사이에 위치합니다.

Layered architecture는 계층 별로 관심사를 분리해 유지보수와 테스트를 편리하게 만드는 것이 핵심입니다.

이중 Presentation layer의 관심사는 필요한 데이터를 클라이언트에게 전달하거나, 클라이언트가 보낸 요청을 서버 내부로 가져오는 것입니다.

따라서 Request는 어떠한 비즈니스 로직도 적용하지 않고, 요청이 부적절하지는 않은지만 검증하는 것이 가장 상식적인 판단일 것입니다. (null 가능성 체크 등)

 

하지만 이 또한 Layered architecture를 만족하는 선에서의 정답일 뿐입니다.

어떤 상황에서는 맹점이 존재할 수도 있습니다.

 

 

2) Request가 도메인 검증 책임을 갖는다.

유연한 검증 책임

Image URL에 규칙을 추가하는 예시를 하나 들어보겠습니다.

class Product {
	private final String imageUrl;
	...
}

도메인 객체인 Product가 imageUrl이라는 필드를 갖는데,

해당 필드의 앞에 반드시 https 가 붙어야된다는 규칙이 추가되었다고 가정해보겠습니다.

Domain 객체의 검증 로직에 해당 내용을 추가한다면,

그 전의 기준이 적용된 DB의 데이터를 꺼내와 Domain 객체를 생성할 수가 없게 됩니다.

imageUrl에 https가 붙어야 한다는 것은 Domain 로직이 확실한데 말이죠.

 

이것을 해결하기 위해 아래와 같은 복잡한 해결책을 택해야 할 수도 있습니다.

  • 데이터베이스 수정 (마이그레이션 or 필드 추가)
  • 새로운 Domain 객체 생성

 

반면에, Request에 검증 책임을 추가한다면 비교적 쉽게 문제를 해결할 수 있습니다.

또, 검증 기준이 바뀌는 경우에도 쉽게 대응할 수 있습니다.

Request(DTO)만을 검증하는 Validator 객체를 만들 수도 있고,

Product를 검증하는 ProductValidator를 상속받거나 구현한 객체를 만들 수도 있습니다.

 

 

철저한 검증

안전한 데이터가 그 어떤 것보다 중요한 환경일 때 (금융 등의 도메인)

요청이 서버에 들어와서 응답을 반환하기까지,

서버 내부에 안전한 데이터만 유지시키고 싶을 수 있습니다.

 

Request에서 Web 환경만을 고려한 데이터를 받는다면,

Domain 객체가 생기기 전까지 Presentation ~ Business layer에 걸쳐

올바르지 않은 데이터가 존재할 수 있습니다.

이것이 잘못 사용되어 Domain에서의 검증을 거치지 않고 DB에 저장되는 등의 행위가 일어난다면,

운영 중인 서비스에 문제가 발생할 수 있습니다.

 

이런 경우, Request에서부터 Domain 기준으로 검증을 한다면

웹 요청 ~ 반환 전체 cycle에 대해 안전한 데이터만 존재하도록 만들 수 있습니다.

또한, 특정 요청에 대해 특수한 검증 로직이 적용되어야 한다면

Request에서 간단하게 수행할 수도 있습니다.

 

하지만 중복된 검증 로직을 별도로 작성하는 것은 유지보수(동기화)가 너무 어려울 수 있습니다.

이때, Domain에서 사용하는 Validator 객체를 사용하거나, 그것을 구현, 상속한 객체를 사용함으로써

관리를 일원화할 수 있습니다.

또한 테스트도 용이해집니다.

 

 

3. Domain과 Request의 검증 책임 정리

제 생각을 토대로 책임을 정리해보면 아래와 같습니다.

Domain 필수

  • Domain 객체로서 변하지 않을 성질
    • ex: Product의 price는 0 이상이어야 한다.
  • 논리를 통한 검증
    • ex: 로또 번호는 서로 같을 수 없다.
  • 저장된 정보와의 비교
    • ex: DB에 저장된 ID와의 중복 비교

 

Request 필수

  • 요청에 대한 올바른 정보인지
    • null 체크
    • 형식 비교 (전화번호, 이메일 등)

 

Request의 검증 로직 확대

  • 가능한 한 Domain에서 해결한다.
    • 10,000원 이하의 핫딜 상품 등록 → Request에서 검증하기 보다, Product를 구현한 HotDealProduct 생성 등
    • imageUrl에 https 필수 → DB 수정을 통해 해결 등
  • 추가될 검증 로직이 요청에 종속되고 변경 가능성이 높은 경우, Request에서 해결할 수 있다. (절충)
    • 특히 Domain의 검증 로직이 변경되기 매우 어려운 경우
  • 서버 내부에 안전한 데이터만 두고 싶을 때
    • 중복된 검증 로직을 동기화할 수 있는 방안이 마련되어야 함 (테스트 등)

 

 


 

결국 문제를 해결하기 위한 여러가지 방안을 떠올리고,

지금과 미래를 고려하여 최적의 방안을 선택하는 것이 중요하다는 생각이 듭니다.

 

의견과 지적을 환영합니다.

감사합니다!

 

 

 

 

 

 
반응형
반응형

서버에서는 요청에 대한 반환만 전달하고, 클라이언트가 화면을 렌더링하는 CSR(Client-Side Rendering) 방식에서는,
Controller가 기능(또는 도메인) 기준으로 분리되는 것이 일반적입니다.

 

그런데 서버에서 요청에 대한 수행 뿐만 아니라, 데이터를 렌더링한 화면을 반환하는 SSR(Server-Side Rendering) 방식에서,
Controller는 어떻게 분리되어야 할지 고민이 되었습니다.

 

화면 단위로 구분해야 하는 것이 맞을지, 기능(또는 도메인 / 이하 도메인)단위로 구분해야 하는 것이 맞을지,
고민의 대한 개인적인 결론을 공유드려봅니다.

 

먼저, 각각의 방식에 대한 예시와 특징을 살펴보고,
마지막으로 각각을 비교하면서 나름의 결론을 내려보겠습니다.

 


화면 단위로 구분하는 예시

현재 웹 사이트가 domain.com/domain.com/admin의 2가지 화면을 가지고 있다고 가정하겠습니다.
아래는 Spring 프레임워크로 구현된 Controller의 의사코드입니다.

// domain.com/ 에서 사용되는 기능을 모아둔 Controller

@Controller
public class HomeController {

    @GetMapping
    public String productList(final Model model) {
        List<Product> products = productDao.findAll();
        model.addAttribute("products", products);
        return "index";
    }
}
// domain.com/admin 에서 사용되는 기능을 모아둔 Controller

@RequestMapping("/admin")
@Controller
public class AdminController {

@PostMapping("/products")
    public void saveProduct(@RequestBody Request request) {
        // (프로덕트 저장 로직)
// 이후 Javascript 파일에서 이전 뷰를 reload함
    }

    @GetMapping
    public String getAllProducts(final Model model) {
        List<Product> products = productService.findAll();
        model.addAttribute("products", products);  // 상품 정보 렌더링 로직
        return "admin";
    }
...
}

화면에 필요한 기능들이 모아져 있습니다.

 

도메인 단위로 구분하는 예시

// Product 관련 기능을 모아둔 Controller

@RequestMapping("/products")
@Controller
public class ProductController {

@PostMapping
public void saveProduct(@RequestBody Request request) {
        // (프로덕트 저장 로직)
// 이후 Javascript 파일에서 이전 뷰를 reload함
    }
...
}

이 경우, 아래와 같이 뷰를 반환하는 Controller가 별도로 존재해야 합니다.
(아니면 기능 단위로 뷰를 분리할 수도 있겠으나 개인적으로는 구조가 직관적이지 않다고 생각합니다.)

// View를 반환하는 Controller
@Controller
public class ViewController {

@GetMapping
public String getHomeView() {
// (렌더링 로직)
return "index";
}

@GetMapping("/admin")
public void getAdminView() {
// (렌더링 로직)
return "admin";
}
}

 

두 가지 방식 비교

Controller의 구조가 화면에 의존하느냐, 도메인에 의존하느냐,
두 가지 관점에서 고려해보겠습니다.

1. 화면과 도메인 중 어느 것이 자주 바뀔까

일반적으로는 화면이 더 자주 바뀔 것입니다.
사용자에게 보여지고, 사용자와 직접 Interaction하는 화면은
사용자의 편의를 직접적으로 고려하고 반영해야 할 일이 많을 것입니다.

 

하지만 도메인의 변경은 자주 일어나서는 안 되는 일입니다.
OCP(Open-Close Principle / 개방-폐쇄 원칙)에 의하면,
객체지향적 설계를 위해 도메인 구조는 변경에는 닫혀 있고, 확장에만 열려 있어야 합니다.

 

따라서 구조의 변경이 자주 일어날 것으로 예상되는 화면보다는,
도메인을 기준으로 Controller 구조를 설계하는 것이 맞다고 생각합니다.
변경 가능성이 적어 안정적인 구조를 위해서 그렇습니다.

2. Controller는 Service에 의존한다.

Service는 아마 기능 단위로 분리되어 있을 것입니다. (도메인 주도 개발의 관점에서 유즈케이스를 고려할 수 있으나, 이 또한 기능 중심의 분리입니다.)
만약 Controller가 화면의 구조에 맞게 설계된다면,
의존하는 Service가 많아질 수 있습니다.

 

예를 들면,
ProductService, UserService, OrderService가 존재할 때,
도메인 기준으로 설계된 ProductControllerProductService에만 의존하겠지만, (물론 의존성이 추가될 수 있습니다.)
화면 기준으로 설계된 AdminControllerProductService, UserService, OrderService를 모두 의존할 수 있습니다.
Admin 화면에서 상품, 유저, 주문 정보에 모두 접근할 수 있기 때문입니다.
따라서 도메인 기준으로 설계된 Controller의 의존 관계가 화면 기준으로 설계된 Controller보다 간결할 가능성이 높습니다.

 

위의 두 가지 이유를 종합하여,
화면 기준이 아닌 도메인(기능) 기준으로 Controller를 설계해야 한다고 결론을 내렸습니다.

 

 


 

물론 정답이 있는 문제는 아니라고 생각합니다. (진리)
사용자가 접근할 URL이 너무나도 중요해서 절대 확장하지 않고 2가지 링크에 대응되는 화면만 사용한다면,
화면이 도메인보다 안정적인 구조일 수도 있습니다.
설계에 따라서는 도메인 중심으로 나눈 ControllerService에 대해 복잡한 의존 관계를 지닐 수 있습니다.

 

그렇기에 무작정 설계를 하기 보다는,
지금의 설계가 추후의 비즈니스적 문제에 어떤 영향을 미칠 것인지를 고민하는 것이 중요하다고 생각합니다.

 

 

개인적인 생각인지라 오류가 있을지 모릅니다.
지적이나, 다른 의견 공유를 환영합니다.

 

감사합니다.

반응형
반응형

 

 

 

@Transactional 애너테이션은 메서드(혹은 클래스, 인터페이스 등)를 트랜잭션 단위로 만듭니다.

여기서 트랜잭션이란, 데이터베이스에서 수행하는 작업의 단위로, 아래와 같은 4가지 특성을 가집니다.

  • 원자성 : 하나의 작업 단위이다. (모두 수행되던가, 전혀 수행되지 않던가)
  • 일관성 : 작업 처리 결과에 일관성이 있다. (트랜잭션 진행되는 동안에 발생하는 DB 변경은 무시한다.)
  • 독립성 : 둘 이상의 트랜잭션이 동시에 실행될 때, 다른 트랜잭션의 연산에 끼어들지 않는다.
  • 지속성 : 트랜잭션이 성공적으로 완료되면, 결과는 영구적으로 반영되어야 한다.

본 아티클에서는 간단하게 원자성을 기준으로 @Transactional 애너테이션을 사용해보겠습니다.

그리고 @Transactional의 적용 범위(하위 메서드, 상위 메서드)에 대해, Spring 내부의 동작 원리를 통해 간단하게 알아보겠습니다.

 

 

트랜잭션이 아닌 경우

아래의 간단한 예시 코드를 먼저 이해해야 합니다.

(Java의 문법과 Spring의 라이브러리, 그리고 약간의 의사코드를 포함하여 작성되었습니다.)

// Dao를 사용하는 Service
public class Service {
    privata final Dao dao;

    public saveResult(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}
// Service가 사용하는 Dao
public class Dao {

    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

ServicesaveResult()DaosaveNameById()saveAgeById()라는 2개의 메서드를 호출합니다.

그를 통해 2개의 SQL문이 실행되고, DB에는 2번의 변경이 일어납니다.

 

이때, 아래와 같이 age를 저장하던 도중 예외가 발생한다고 가정하겠습니다.

public void saveAge(int age) {
    throw new RuntimeException("저장 실패");
    this.database.executeQuery("UPDATE user SET age = ? WHERE id = 1", age) // 입력 받은 age를 DB에 저장ㅇ하는 의사코드
}

이런 상황에서 Servicesave() 메서드가 호출된다면,

DB에서 해당 username까지만 저장되고, age는 저장되지 않을 것입니다.

 

예외가 발생한다면 대개의 경우,

name만 저장되는 상황보다는 nameage가 모두 저장되지 않는 상황이 바람직할 것입니다.

@Transactional 애너테이션으로 그것을 구현해보겠습니다.

 

 

트랜잭션을 사용하는 경우

public class Service {
    privata final Dao dao;

    @Transactional
    public saveResult(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}

하나의 단위로 묶이기 원하는 메서드나 @Transactional 애너테이션을 붙입니다.

그럼 해당 메서드가 호출되어 수행이 완료되기 까지 발생하는 DB와의 모든 상호작용이 하나의 단위가 됩니다.

위에서 작성한 코드의 예를 들면, nameage를 저장하는 작업이 하나로 묶여서,

모두 성공하면 반영(commit)되고, 하나라도 실패하거나 중간에 문제가 발생하면 회수(rollback)됩니다.

 

 

nameage를 저장하는 Dao의 메서드 각각을 트랜잭션 단위로 만들고 싶다면,

아래와 같이 사용할 수 있습니다.

public class Dao {

    @Transactional
    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    @Transactional
    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

 

클래스나 인터페이스의 모든 메서드에 @Transactional을 붙여야 한다면,

아래와 같이 클래스나 인터페이스 상단에 1번 붙이는 것으로 대체할 수 있습니다.

@Transactional
public class Dao {

    public void saveNameById(String name, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 name을 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET name = ? WHERE id = ?", name);
    }

    public void saveAgeById(int age, int userId) {
        // DB에서 id가 userId인 user에 입력 받은 age를 저장하는 의사코드
        this.database.executeQuery("UPDATE user SET age = ? WHERE id = ?", age);
    }
}

 

스프링 공식 문서에서는, 인터페이스에 주석을 추가하기보다, 구체적인 클래스나 메서드에 주석을 추가할 것을 권장합니다.

예상대로 동작하지 않을 수 있기 때문인데요, 자세한 사항은 공식 문서에서 설명하고 있습니다.

 

 

@Transactional의 적용 범위

@Transactional의 사용 시, 예상대로 동작하지 않을 수 있는 사례 한 가지를 소개하고자 합니다.

그것은 바로 내부 호출입니다.

public class Service {
    privata final Dao dao;


    public saveResult(int userId) {
        this.saveResultByDao(userId); // Dao를 사용하는 로직을 새로운 메서드로 분리
    }

    @Transactional
    public saveResultByDao(int userId) {
        this.dao.saveNameById("name", userId);
        this.dao.saveAgeById(40, userId);
    }
}

(상기 코드는 상위의 ControllerServicesaveResult() 메서드를 호출한다고 가정하겠습니다.)

위의 코드는, saveResult() 메서드 내부에서 Dao를 사용하여 DB를 수정하는 로직을 분리한 코드입니다.

nameage를 저장하는 행위가 하나의 트랜잭션으로 묶일 것 같지만, 그렇지 않습니다.

내부에서 호출하는 함수의 @Transational 애너테이션이 무시되기 때문입니다.

 

 

@Transactional은 프록시 모드에서 작동하고, 프록시를 통해 들어오는 외부 메서드 호출만 가로채기 때문입니다. 이를 정확하게 이해하려면 프록시 패턴을 사용하는 스프링 AOP를 이해해야 합니다. 본 아티클에서는 이정도로만 다루겠습니다.

(설정을 통해 모드를 변경하면, 내부 호출도 @Transactional을 적용할 수 있다고 합니다. 자세한 내용은 공식 문서를 참고해주세요.)

 

정리하자면, @Transactional은 프록시를 통해 들어오는 외부 메서드 호출만 가로채기 때문에 내부에서 호출되는 메서드에 추가된 @Transactional은 무시됩니다.

 

 


 

 

잘못된 내용이나 궁금하신 점을 댓글로 남겨주시면 감사하겠습니다.

감사합니다!

 

 

 

 

반응형

+ Recent posts