헥사고날 아키텍처를 사용한 스프링 부트 로그인 API 구현해보기
목적
클린 아키텍처라는 책을 읽으면서 프로젝트의 아키텍처 자체에 관심을 가지게 되었습니다. 저자가 제시한 클린 아키텍처는 이론적인 의미는 이해하였으나 구현 방향에 대한 갈피를 잡지 못했습니다. 그러다 헥사고날 아키텍처에 대해 알게 되었습니다. 클린 아키텍처에서 제시한 핵심 정책의 분리에 대한 원칙을 지키고 있으며 좀 더 실무에서 유용하게 사용하고 있다는 사실을 알고 직접 구현해보며 이해하고 싶었습니다. 그래서 흔한 기능 중 하나인 로그인에 대해 헥사고날 아키텍처로 설계해 보았습니다. 직접 설계해보며 느낀점에 대해 포스팅 해볼까 합니다.
프로젝트 구조
프로젝트 구조는 그림과 함께 보면 이해가 쉬울거라 생각해서 같이 가져왔습니다.
각 계층에 대해 살펴보자면,
Adapter
각 핵심 로직과 외부를 연결하는 계층입니다. DB에서 데이터를 가져오는(out) 부분과 api의 직접적인 통신을 담당하는(in) Controller 부분이 해당합니다. 클린 아키텍처에서는 프로젝트를 설계할 때 이 부분을 제일 뒤로 미뤄 결정할수록 더 좋은 아키텍처 설계가 나온다고 말하고 있습니다. 즉, 어떤 db를 사용할건지, api 통신을 담당하는 애플리케이션이 무엇인지에 대해서는 핵심 로직을 설계하는데 전혀 영향이 없어야 합니다.
어댑터의 in 부분에는 크게 dto, response, rest 패키지가 있습니다. dto는 데이터를 전달하는 객체입니다.
@Getter
@Setter
@ToString
public class LoginDto {
@NotBlank(message = MessageUtil.BLANK_ID)
@Size(max = 15, min = 4, message = MessageUtil.INVALID_LENGTH_ID)
private String userId;
@NotBlank(message = MessageUtil.BLANK_PASSWORD)
@Size(max = 20, min = 8, message = MessageUtil.INVALID_LENGTH_PASSWORD)
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"
, message = MessageUtil.INVALID_PASSWORD)
private String userPw;
}
외부에서 전달받은 데이터에 대한 검증과 함께 lombok 라이브러리를 사용한 getter setter 어노테이션을 사용하고 있습니다. 단순히 어댑터 계층에서 전달받은 데이터 객체이기 때문에 불변성을 보장하지 않습니다.
// 컨트롤러 Response 객체
public class Response {
int status;
String message;
Object data;
public Response() {}
public Response(int status, String message, Object data) {
this.status = status;
this.message = message;
this.data = data;
}
public Response(int status, String message) {
this.status = status;
this.message = message;
}
}
Response 계층은 컨트롤러에서 return값에 대한 통일성을 위해 만든 객체입니다. 상태값, 메시지, 전달할 데이터 형태로 구성되어 있으며 데이터가 필요하지 않을 경우를 대비해 상태값, 메시지만 전달할 수도 있습니다.
@RestController
@RequestMapping(value = "/v1/api")
@RequiredArgsConstructor
public class LoginController {
private final LoginUseCase loginUserCase;
final Logger log = LogManager.getLogger(getClass());
@PostMapping("/login")
public Response postLogin(@Valid @RequestBody LoginDto loginDto) throws Exception {
return loginUserCase.login(loginDto);
}
}
컨트롤러 계층을 보면 더 이해가 쉬울 거라고 생각합니다. 핵심 로직에서 DI한 객체 loginUserCase를 생성자 주입을 통해 가져왔습니다. 그리고 로그인 api의 리턴값은 방금 본 Response 객체에 담아 전달하고 있는 것을 확인할 수 있습니다. 엄밀히 따지면 loginUserCase에서 Response객체를 리턴값으로 사용하고 있는것을 확인할 수 있습니다. 그래서 이 Response를 aplication 패키지에 넣어야 하나 고민했으나 의미적으로 어댑터 계층에서 담아 리턴하는게 맞는 것이라 판단해 adapter 패키지에 포함하게 되었습니다.
@Entity
@Table(name="T_USER")
@Getter
@Setter
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userSeq;
@Column(name = "user_id", length = 15, nullable = false)
private String userId;
@Column(name = "user_pw", length = 20, nullable = false)
private String userPw;
@Column(name = "phone_number", length = 15, nullable = false)
private String phoneNumber;
}
adpater의 out 패키지는 db에서 직접적으로 데이터를 꺼내는 부분입니다. mybatis를 사용한다면 dao계층이 들어올 수 있겠으나 지금은 JPA를 사용했기 때문에 다음과 같이 구성되어 있습니다.
public interface JpaUserRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByUserId(String userId);
}
다음은 persistence 패키지입니다. entity 패키지가 db에서 추출할 데이터에 대해 정의하는 역할을 맡았다면 이곳은 데이터를 어떤 방식으로 추출할 것인가에 대해 정의하는 공간입니다. JpaUserRepository는 유저 아이디를 통해 db에서 유저 정보를 가져옵니다.
@Component
@RequiredArgsConstructor
public class UserPersistenceAdapter implements LoginUserPort {
private final JpaUserRepository jpaUserRepository;
@Override
public User findByUserId(String userId) {
UserEntity userEntity = jpaUserRepository.findByUserId(userId);
User user = new User();
if(userEntity == null) return null;
user = User.builder()
.userSeq(userEntity.getUserSeq())
.userId(userEntity.getUserId())
.userPw(userEntity.getUserPw())
.phoneNumber(userEntity.getPhoneNumber())
.build();
return user;
}
}
UserPersistenceAdapter는 뒤에나올 application의 port 패키지에서 사용하는 loginUserPort를 구현한 객체입니다. DI를 위해 추상화한 port의 기능을 adapter에서 구현함으로써 application 계층은 데이터를 조회하는 것에 대해 상세한 정보를 모른 채 추상화한 객체를 가져와 사용할 수 있습니다. 해당 부분에서는 builder 패턴을 사용한 User객체를 리턴하는 것을 알 수 있습니다.
User객체는 domain 계층에서 정의한 model입니다. 이 부분에 대한 설명은 뒤에 domain계층 부분에서 설명하도록 하겠습니다.
더불어 null 체크도 하는 것을 알 수 있는데 이 부분에 대해 application 계층에서 진행해야할지 고민했으나 '데이터 자체에 직접적으로 관련된 기능' 이라고 생각해서 목적의 분리를 위해 adapter계층에서 진행하게 되었습니다.
Application
직접적인 비즈니스 로직이 들어가는 부분입니다. 그러나 위 계층에 대해 더 세세히 들어가보자면,
어플리케이션 내에 port 패키지가 존재합니다. 이는 어댑터 계층에서 가공된 데이터가 적절한 추상화를 통해 비즈니스 로직에서 사용될 수 있게 합니다. 핵심 로직에 대한 철저한 분리를 위해 꼭 필요한 패키지라고 할 수 있습니다. 핵심 로직은 service 패키지 내에 위치하게 됩니다.
public interface LoginUseCase {
public Response login(LoginDto loginDto) throws Exception;
}
port의 in패키지에는 api와 직접적으로 통신하는 controller와 연관이 있습니다. login에 대한 비즈니스 로직을 마찬가지로 추상화 함으로써 adapter와의 관계를 분리합니다.
public interface LoginUserPort {
public User findByUserId(String userId);
}
out 패키지의 LoginUserPort는 db와 연관되어 있습니다. 앞에서 봤던 persistence계층에서 이 인터페이스를 구현하고 있습니다. 마찬가지로 관심사의 분리를 목적으로 하고 있습니다.
@Service
@RequiredArgsConstructor
public class LoginService implements LoginUseCase {
private final LoginUserPort loginUserPort;
@Override
public Response login(LoginDto loginDto) throws Exception {
// 유저 정보 찾기
User user = findByUserId(loginDto);
if(user == null) {
return new Response(200, MessageUtil.USER_NOT_EXIST);
}
// 비밀번호 검증
if(InvalidatePassword(user, loginDto.getUserPw())) {
return new Response(200, MessageUtil.DIFF_PASSWORD);
}
return new Response(100, MessageUtil.LOGIN_SUCCESS, user);
}
public User findByUserId(LoginDto loginDto) throws Exception {
String userId = loginDto.getUserId();
return loginUserPort.findByUserId(userId);
}
public boolean InvalidatePassword(User user, String userPw) throws Exception {
String userInfoPw = user.getUserPw();
return !userInfoPw.equals(userPw);
}
service 패키지는 비즈니스 로직입니다. 로그인에 필요한 로직들을 처리합니다. service 계층은 도메인의 model을 적절히 사용합니다. 이는 계층 간 결합도를 낮춤으로써 관심사의 분리를 이끕니다.
Domain
이는 헥사고날 아키텍처에서 가장 코어한 부분을 담당합니다. 클린 아키텍처에서는 이를 가장 고수준의 정책이며, 가장 코어한 부분에 위치하여야 한다고 말합니다. 가장 변경이 적고 가장 안정된 상태를 유지하여야 합니다. 안정된 상태라 함은 다른 외부 라이브러리나 객체에 의존적이지 않아야 하며 반대로 저수준의 정책이 이 부분을 의지하여야 합니다.
도메인의 모델 패키지는 어플리케이션 계층과 협력하여 비즈니스 로직을 처리하는데 사용됩니다.
아키텍처를 만들며 유틸리티 클래스를 도메인 계층에 포함시키는 것은 어떨까 생각해보았습니다. 유틸리티 특성 상 안정된 상태를 유지하여야 하고 변화가 적으므로 고수준의 정책에 알맞다고 생각했습니다. 그러나 유틸 클래스는 애플리케이션 계층 뿐만 아니라 전 계층에서 사용해야 한다고 판단하여 아예 다른 패키지로 분리하였습니다.
다시 도메인으로 돌아와 살펴보자면,
@Getter
@NoArgsConstructor
public class User {
private Long userSeq;
private String userId;
private String userPw;
private String phoneNumber;
@Builder
public User(Long userSeq, String userId, String userPw, String phoneNumber) {
this.userSeq = userSeq;
this.userId = userId;
this.userPw = userPw;
this.phoneNumber = phoneNumber;
}
}
User 클래스는 lombok의 빌더 어노테이션을 사용하여 빌더 패턴을 사용하였습니다. 또 setter 어노테이션을 사용하지 않았는데 이는 객체의 불변성을 지키기 위해서입니다. 가장 고수준의 정책을 유지해야 하는 도메인 계층의 모델의 데이터를 마음대로 변경하는 것은 아키텍처의 설계 원칙에 반하기 때문입니다. 여러명의 개발자의 협업할 때 더욱 중요한 요소라고 생각합니다.
Util
util 클래스는 별도로 코드를 첨부하진 않았습니다. 위 코드를 보셨다면 짐작 가능하겠지만 MessageUtil 클래스를 통해 메시지를 이곳에서 할당하고 있다는 것을 알 수 있습니다. 이처럼 유틸 클래스에서는 모든 계층에서 사용이 가능하며 정적 타입(static)의 클래스를 제공합니다. 이는 최초 프로젝트 빌드 시 생성되어 재사용이 가능한 객체임을 알 수 있습니다. calendarUtil, StringUtil등의 클래스가 포함될 수 있습니다.
정리
헥사고날 아키텍처는 코어 계층(domain)과 핵심 비즈니스 로직(application), 그리고 외부 계층과 연결하는(adapter) 구조로 나뉘어 있다는 것을 알 수 있었습니다. 가장 중요한 개념은 관심사의 분리를 통해 비즈니스 로직이 외부의 변경에 대비해 유연하게 대처가 가능하다는 것을 알았습니다.
간단한 프로젝트에서 이 아키텍처를 사용하는 것은 적절하지 않다는 것을 알았습니다. 모놀리식 아키텍처 구조보다 복잡하여 프로젝트 구성원이 아키텍처에 대한 지식이 없다면 일정이 늘어날 수 있으며 실제로도 패키지나 클래스의 개수가 늘어나서 개발이 더 오래 걸립니다.
그러나 헥사고날 아키텍처의 강점은 대규모의 프로젝트, 더 나아가 MSA를 구현할 때 입니다. 여러개의 기능들을 다수의 헥사고날 아키텍처로 제작하여 기능 단위로 프로젝트를 관리한다면 다수의 개발자가 더 효과적으로 기능을 구현할 수 있을 것이며 데브옵스 환경을 구축하는데 있어서도 큰 도움이 될 것이라 생각합니다.
개념적으로 공부하는 것도 좋지만 직접 프로젝트를 만들어서 패키지가 어떻게 구성되는지, api가 어떤 순서대로 흘러가는지 직관적으로 확인할 수 있어 더 뜻깊었습니다.