본문 바로가기
Development/Spring Boot

스프링부트 헥사고날 아키텍쳐 코드 구조

by Nahwasa 2023. 2. 17.

스터디 메인 페이지

 

* 헥사고날 아키텍쳐를 이제 공부 시작해보는거라 틀린 내용이 있을 수 있습니다.

 


 

  헥사고날 아키텍쳐 스터디를 시작했는데, 책(만들면서 배우는 헥사고날 아키텍처 설계와 구현)의 개념들이 초반부터 좀 어려웠다. 구체적인 코드를 먼저 짜보지 않고 설명만 보면 너무 이해가 더딜 것 같았다.

 

  내 경우에 스프링부트로 프로젝트를 만들 때 사용할 아키텍쳐를 공부해보기 위해 시작한거라 책에서 설명한 헥사고날 구조가 스프링부트에 어떤식으로 적용될지부터 감이 와야 이후 내용을 진행할 수 있을거라 생각했다.

 

  그래서 책의 내용을 보고 예상되는 코드 구조를 한번 짜봤다. 현업에서 현재 헥사고날 아키텍쳐를 적용중인 분께 여쭤보니 다행히 코드 구조 자체는 헥사고날 구조가 맞다고 들어서 만들어본 코드를 기준으로 헥사고날 아키텍쳐를 학습해보면서 점점 구조를 수정해나가보려고 한다.

 

 

  우선 헥사고날 구조로 바꿔볼 기존 코드는 전형적인 레이어드 아키텍쳐 구조로 짠 코드이다. (github)

 

  헥사고날로 바꿔본 코드의 패키지 구조는 아래와 같다. (github)

 

  (config는 스프링 시큐리티 등의 세팅인데 헥사고날 구조에서 별도로 들어갈 만한 곳은 없는 것 같아서 그냥 별도 패키지로 두었다.)

 


 

도메인 헥사곤

도메인(엔티티)

  레이어드 아키텍쳐에서도 자주 보는 도메인 객체이다. 책에서는 도메인 헥사곤에 엔티티와 값 객체(VO)가 나온다. 이 프로젝트에서는 VO는 당장 필요없는 것 같다. 다른 곳에 의존성이 없도록 주의해서 작성했다. 예를들어 PasswordEncoder는 스프링 컨테이너에게 주입받아도 되지만, 엔티티에서 주입받고 싶지 않았으므로 정적 팩토리 메소드를 사용하는 쪽에서 주입해주는 구조로 정했다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String userid;

    private String pw;

    private String roles;

    private Member(Long id, String userid, String pw, String roleUser) {
        this.id = id;
        this.userid = userid;
        this.pw = pw;
        this.roles = roleUser;
    }

    protected Member() {}

    public static Member createUser(String userId, String pw, PasswordEncoder passwordEncoder) {
        return new Member(null, userId, passwordEncoder.encode(pw), "USER");
    }

    public Long getId() {
        return id;
    }

    public String getUserid() {
        return userid;
    }

    public String getPw() {
        return pw;
    }

    public String getRoles() {
        return roles;
    }
}

 


 

어플리케이션 헥사곤

어플리케이션 헥사곤에는 유스케이스, 입력 포트, 출력 포트가 포함된다.

 

유스케이스

  입력 포트를 통해 사용될 유스케이스이다. 우선 한 명의 멤버를 찾는 유즈케이스와, 멤버 회원가입 유즈케이스를 두었다. 입력 어댑터에서 유스케이스나 입력 포트를 통해 도메인을 넣어주는건 말이 안되는 것 같아서 도메인을 리턴은 해주더라도 입력은 dto 등 다른 방법이 좋을 거라 생각했다. 내 경우엔 필요한 정보가 적어 일단 인자로 그냥 받았다.

public interface FindOneMemberUseCase {

    Optional<Member> findOne(String userId);
}

---

public interface JoinMemberUseCase {

    void join(String userid, String pw);
}

 


 

입력 포트

  입력 포트의 역할은 유스케이스를 구현하는 것이다. 출력 포트(DB 접근)를 사용해 유스케이스를 구현해준다.

@Service
public class FindMemberInputPort implements FindOneMemberUseCase {
    private final MemberFindOutputPort memberFindOutputPort;

    public FindMemberInputPort(MemberFindOutputPort memberFindOutputPort) {
        this.memberFindOutputPort = memberFindOutputPort;
    }


    @Override
    public Optional<Member> findOne(String userId) {
        return memberFindOutputPort.findOne(userId);
    }
}

---

@Service
public class RegisterMemberInputPort implements JoinMemberUseCase {

    private final MemberJoinOutputPort memberJoinOutputPort;

    public RegisterMemberInputPort(MemberJoinOutputPort memberJoinOutputPort) {
        this.memberJoinOutputPort = memberJoinOutputPort;
    }

    @Override
    public void join(String userid, String pw) {
        memberJoinOutputPort.join(userid, pw);
    }
}

 


 

출력 포트

  외부 리소스에서 데이터를 가져오는 역할이다. 출력 포트는 인터페이스로 추상화하고, 실제 구현은 출력 어댑터에 할당하게 된다.

public interface MemberFindOutputPort {

    Optional<Member> findOne(String userId);
}

---

public interface MemberJoinOutputPort {

    Long join(String userid, String pw);
}

 


 

프레임워크 헥사곤

입력 어댑터와 출력 어댑터가 포함된다.

 

입력 어댑터 (드라이빙 오퍼레이션)

  외부에서 헥사곤에 동작을 요청하는 곳이다. 컨트롤러가 있는 곳으로 생각했다. security쪽에 있는 클래스는 로그인 시 비밀번호를 확인하고 UserDetails를 만드는 곳이다. 이것도 로그인이라는 외부의 요청으로 판단되어 입력 어댑터쪽에 두었다.

 

  이 때 입력 어댑터에서는 유스케이스만을 사용해서 내부에 접근 가능하다. 저기에 입력 포트를 두어야할지 유스케이스를 두어야할지 좀 고민했는데, 추상화 계층인 유스케이스를 입력 어댑터에 두는게 맞다고 생각되었다.

@RestController
@RequestMapping("/auth")
public class AuthorizationController {

    private final JoinMemberUseCase joinMemberUseCase;

    public AuthorizationController(JoinMemberUseCase joinMemberUseCase) {
        this.joinMemberUseCase = joinMemberUseCase;
    }

    @PostMapping("/join")
    public ResponseEntity<String> join(@RequestBody MemberJoinEntity dto) {
        try {
            joinMemberUseCase.join(dto.getUserid(), dto.getPw());
            return ResponseEntity.ok("join success");
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
}

 


 

출력 어댑터 (드리븐 오퍼레이션)

  외부에서 소프트웨어 요구사항을 충족시키는 데 필요한 데이터를 가져오는 부분이다. 대표적으로 데이터베이스가 여기에 포함되면 된다. 이 때, 출력 어댑터쪽에서 추상화 계층인 출력 포트를 구현해주게 된다.

public interface DataJpaMemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUserid(String userId);
}

---

@Repository
public class MemberRepository implements MemberFindOutputPort, MemberJoinOutputPort {

    private final PasswordEncoder passwordEncoder;
    private final DataJpaMemberRepository repository;

    public MemberRepository(PasswordEncoder passwordEncoder, DataJpaMemberRepository repository) {
        this.passwordEncoder = passwordEncoder;
        this.repository = repository;
    }

    @Override
    public Optional<Member> findOne(String userId) {
        return repository.findByUserid(userId);
    }

    @Override
    public Long join(String userid, String pw) {
        Member member = Member.createUser(userid, pw, passwordEncoder);
        validateDuplicateMember(member);
        repository.save(member);

        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        repository.findByUserid(member.getUserid())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }
}

 


 

전체적인 흐름

* 헥사고날 구조 그림이 아니라, 그냥 제가 짠 코드를 기준으로 호출 순서를 제 생각대로 그린겁니다.

1. 입력 어댑터를 통해 외부에서 요청 들어옴

2. 입력 어댑터에서는 추상화된 유스케이스를 사용하지만, 실제 주입되서 사용되는건 입력 포트

3. 유스케이스에서 추상화된 출력 포트를 사용하지만, 실제 주입되서 사용되는건 출력 어댑터

4. 도메인을 사용하는건 출력 어댑터. (입력 어댑터쪽도 유스케이스를 통해 도메인을 리턴받을 수 있음)

 

도메인의 Usages를 보면 출력 어댑터에서만 사용된 것을 확인 가능하다. 

 

댓글