Architecture

클린 아키텍처(Clean Architecture)

왕돼지티라노 2024. 8. 12. 17:26
반응형

클린 아키텍처를 알아보기 전에, 레이어드 아키텍처의 문제점에 대해 짚고 넘어가도록 하겠다.

레이어드 아키텍처는 크게 도메인 패키지, 서비스 패키지, 웹 패키지로 구성되어있다.

웹 패키지가 서비스 패키지에 의존하고, 서비스 패키지가 도메인 패키지에 의존하고 도메인 패키지는 영속성(JPA)에 의존하게 되는 구조이다. 

여기서 레이어드 아키텍처의 문제점은, 영속성 레이어가 기반이 된다는 것이다. 이게 무슨 말이냐면 DB 구조나 세부사항이 도메인, 서비스, 웹 통틀어서 영향을 줄 수 있다는 것이다.

레이어드 아키텍처의 JPA Entity 클래스를 보면 영속성 레이어의 세부사항이 많이 노출되어있다. 기본적으로, @Entity, @Column, @GeneratedValue 등 jakarta.persistence의 어노테이션에 의존한다. 또한, 해당 도메인 클래스가 데이터베이스에 어떤 구조로 저장되는지, 데이터베이스에 저장될 때 어떤 제약조건을 가지는지(nullable..)를 노출하고 있다. 이는 도메인 클래스가 영속성 레이어의 세부사항에 의존하고 있다는 것을 의미하며, 이렇게 되면 영속성 레이어의 사소한 변화에도 도메인이 민감하게 변경되어야한다. 

Spring Data JPA의 제약조건도 영향을 끼친다. Spring Data JPA의 값 객체나 엔티티는 반드시 기본 생성자를 가져야한다. 이 때문에 값 객체는 비즈니스 로직 상 불필요한 기본 생성자를 가지게 된다. 불변객체가 되지 못하는 것이다.

 

클린 아키텍처(Clean Architecture)

클린 아키텍처란 기존의 계층형 아키텍처에서 벗어나고 의존성을 최소화하는 설계를 말한다. 클린 아키텍처의 핵심 아이디어는 소프트웨어 시스템을 다양한 레이어와 컴포넌트로 구성하고, 각 컴포넌트 사이의 의존성을 역전시켜 유연하고 유지보수 가능한 시스템을 만드는 것이다.

출처: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

 

의존성 역전 원칙(Dependency Inversion Principle)

클린 아키텍처에서는 고수준 모듈이 저수준 모듈에 의존하는 대신, 추상화에 의존해야 한다는 원칙을 강조한다. 이는 인터페이스나 추상 클래스를 통해 의존성을 정의하고, 구체적인 구현에 의존하지 않도록 하는 것을 의미한다. 이를 통해 시스템의 의존성이 역전되어 변경에 대해 더욱 유연하고 확장 가능한 아키텍처를 구현할 수 있다.

 

경계(Boundary)

클린아키텍처는 시스템을 내부와 외부로 구분하는 경계를 정의한다. 외부 요소는 사용자 인터페이스(UI), 데이터베이스, 외부 서비스 등을 포함하며, 내부 구성요소는 이러한 외부 요소와의 결합을 최소화하도록 설계된다. 이를 통해 시스템의 내부 구조는 변경에 대해 격리되며, 테스트 및 유지보수가 용이해진다.

 

레이어(Layer)

클린 아키텍처는 레이어 아키텍처를 사용하여 시스템을 구성한다. 일반적으로 표현(Presentation), 애플리케이션(Application), 도메인(Domain), 인프라스트럭처(Infrastructure)와 같은 레이어를 포함한다. 각 레이어는 특정한 역할과 책임을 가지며, 의존성은 내부에서 외부로 향하도록 구성된다. 이를 통해 시스템은 레이어 간의 분리와 모듈화를 통해 유지보수 가능한 구조를 유지할 수 있다.

 

클린 아키텍처는 코드의 의존성과 결합도를 최소화하고 유지보수성과 확장성을 향상시키는 중요한 개념과 원칙을 제공한다. 이를 통해 소프트웨어 시스템은 변경에 유연하게 대응하고 품질을 향상시키며, 개발자들은 유지보수 가능한 코드를 작성할 수 있다. 

 

클린 아키텍처는 도메인 영역을 가장 중심으로 두고 이를 기반으로 어플리케이션(서비스), 어댑터 등 다른 영역이 이를 의존하는 방식으로 구현한다.

 

도메인 영역

기본에 레이어드 아키텍처에서 만들어진 도메인 객체에서 JPA 등 영속성 세부사항들을 제거하는 방식으로 구현한다. 클린아키텍처는 이런 도메인 객체를 통해 비즈니스 로직을 구현해낸다.

애플리케이션 영역

애플리케이션 영역은 도메인 영역을 활용해서 비즈니스 로직을 호출하는 역할을 한다. 어플리케이션 영역은 크게 포트, 유스케이스, 커맨드라는 개념이 존재한다. 클린 아키텍처에서 포트는 외부와 어플리케이션이 소통할 수 있는 인터페이스이다. 일반적으로 어플리케이션이 외부의 기능을 호출하고자 할 때 외부 영역에 의존하지 않도록 의존성을 역전하는 방식으로 설계된다. 유스케이스는 포트의 일종으로 외부에서 도메인 코어의 기능을 호출할 때 사용되는 인터페이스로 이해하면 쉽다. 커맨드는 외부에서 유스케이스를 호출할 때 전달되는 매개변수 역할을 한다고 생각하면 쉽다. 일반적으로 커맨드 클래스를 통해 유스케이스의 입력값을 검증한다.

아래는 학생 정보를 추가하는 유스케이스를 자바 코드로 작성한 예시이다. 가능한 하나의 인터페이스 당 하나의 유스케이스를 다루도록 분리해서 작성하는 게 좋다(ISP 원칙). 매개변수로 커맨드 객체를 받도록 되어있다.

public interface AddStudentUseCase {
    Long addStudent(AddStudentCommand command);
}

 

아래는 위의 유스케이스에서 매개변수로 사용되는 커맨드 클래스를 코드로 작성한 예시이다. 커맨드는 유스케이스 입력값에 해당하는 개념으로 내부에서 입력값 검증을 책임질 수 있다.

@Getter
public class AddStudentCommand {
    private final String name;
    private final String address;
    private final String grade;
    
    public AddStudentCommand(String name, String address, String grade){
        CommandUtil.throwIfNullOrBlank(name, "name is null or blank.");
        CommandUtil.throwIfNullOrBlank(address, "address is null or blank.");
        CommandUtil.throwIfNullOrBlank(grade, "grade is null or blank.");
        
        this.name = name;
        this.address = address;
        this.grade = grade;
    }
}

어플리케이션 영역에는 유스케이스를 구현한 도메인 서비스가 존재한다. 도메인 서비스는 도메인 객체를 호출하거나 다른 영역의 기능을 호출하여 구체적인 비즈니스 로직을 구현한다.

다음은 학생을 추가하는 유스케이스를 구현하는 도메인 서비스의 예시이다.

@Service
@Transactional
@RequiredArgsConstructor
public class AddStudentService implements AddStudentUseCase {

    private final AddStudentPort addStudentPort;
    
    @Override
    public Long addStudent(AddStudentCommand command) {
        Grade grade = new Grade(new BigDecimal(command.getGrade()));
        
        Student student = new Student(null, command.getAddress, command.getName(), grade);
        
        return addStudentPort.save(student).getId();
    }
}

위 서비스 클래스는 유스케이스를 구현(implements)한다. 커맨드를 기반으로 도메인 객체를 만들어서 포트를 통해 저장하는 책임을 외부로 위임한다.

아래는 저장을 책임지는 포트의 코드이다.  포트는 유스케이스와 비슷하게 어플리케이션이 외부와 소통하는 인터페이스 형식으로 작성된다. 이 인터페이스의 구현체는 어플리케이션 영역이 아닌 어댑터 영역에 존재하게 된다.

public interface AddStudentPort {
    Student save(Student student);
}

 

어댑터 영역

어댑터 영역은 포트를 구현하거나 애플리케이션의 유스케이스를 호출하는 영역이다. 보통 일반적인 웹 백엔드에서 어플리케이션을 호출하는 웹 어댑터(컨트롤러), 포트를 구현하는 영속성 어댑터가 존재한다.

AddStudentPort라는 포트를 구현하는 어댑터 코드이다. AddStudentJpaAdapter는 JPA를 활용해서 구현했다. 클린 아키텍처의 도메인 모델은 JPA 세부사항과는 독립된 순수 자바 객체이므로, 어댑터 영역에서 어댑터 구현을 위한 JPA Entity들을 따로 관리한다. 영속성과 관련된 세부사항을 어댑터 영역에 모아두면 어플리케이션 영역에서는 영속성 세부사항을 몰라도 비즈니스 로직을 구현할 수 있게 된다.

아래는 어플리케이션의 포트를 구현하는 영속성 어댑터 코드 예시이다. 영속성 세부 사항에 해당하는 JPA Entity와 Repository를 활용하여 포트의 요구사항을 구현했다.

@Repository
@RequiredArgsConstructor
public class AddStudentJpaAdapter implements AddStudentPort {
    
    private final StudentJpaRepository jpaRepository;
    
    @override
    public Student save(Student student) {
        Grade grade = grade.getGrade();
        
        StudentEntity entity = new StudentEntity(
            null, 
            student.getAddress(), 
            student.getName(),
            grade
        );
        
        jpaRepository.save(entity);
        
        return new Student(
            entity.getId(),
            entity.getAddress(),
            entity.getName(),
            grade
        );
    }
}

 

아래는 어댑터 구현을 위해 만들어진 JPA Entity인 StudentEntity 코드이다. 코드를 보면 비즈니스 로직없이 영속성 세부 사항을 다루는 데 집중하고 있다. 비즈니스 로지깅 도메인 영역과 어플리케이션 영역에 집중되도록 설계했기 때문에 가능한 일이다.

@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentEntity {
    
    @Id
    @Setter(AccessLevel.NONE)
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    
    @Column(nullable = false)
    private String address;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private String grade;
}

 

마지막으로 어플리케이션의 유스케이스를 호출하는 웹 어댑터이다. 일반적으로 익숙한 컨트롤러 형태를 가지게된다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/student")
public class studentController {
  
    private final AddStudentUseCase addStudentUseCase;
    
    @PostMapping
    public ResponseEntity addStudent(@RequestBody AddStudentDto addStudentDto) {
        AddStudentCommand addStudentCommand = new AddStudentCommand(
            addStudentDto.getName(),
            addStudentDto.getAddress(),
            addStudentDto.getGrage()
        );
        
        Long id = addStudentUseCase.addStudent(addStudentCommand);
        
        return ResponseEntity.created(URI.create("/student/" + id)).build();
    }
}

 

패키지 구조

지금까지 학생을 추가하는 기능을 클린 아키텍처로 구현해보았다. 클린 아키텍처는 의존성의 흐름이 도메인 영역으로 흐르도록 설계해야 하기 때문에 각 클래스를 적절한 패키지로 구분하는 것이 중요하다. 큰 단위로 보면 domain, application, adapter로 패키지를 나누어 구현하였다.

클린 아키텍처는 도메인을 기반으로 영속성 세부 사항에 영향을 덜 받을 수 있는 장점을 가진다. 하지만 클린 아키텍처는 기존보다 많은 클래스와 인터페이스를 작성해야 하고, 더 복잡한 패키지 구조를 가지는 단점도 존재한다. 

따라서 프로젝트의 특성에 따라 비즈니스 로직이 간단한 프로젝트는 레이어드 아키텍처를 선택하는 것이 좋을 수도 있다.

반응형

'Architecture' 카테고리의 다른 글

계층형 아키텍처(Layered Architecture)  (0) 2024.08.05