아키텍처 선택
레이어드 아키텍처로 진행했던 프로젝트 중 의존성 관리가 어려워진 경험이 있었다.
처음에는 간단했던 구조가 클래스가 많아지고 구조가 점점 복잡해지면서 단일 책임 원칙 위반과 중복 코드 문제가 발생했었다.
이러한 문제를 겪고 프로젝트 아키텍처에 공부를 했고, 내가 겪은 문제를 시원하게 해결해줄 아키텍처가 있다는 것을 알았다.
레이어드 아키텍처의 문제점
레이어드 아키텍처는 controller - service - repository 구조를 가지고 있다.
이 구조를 모식도와 계층으로 표현하면 다음과 같다.

이 구조의 장점은 구조가 단순하고 직관적이다.
각 계층의 역할이 명확하게 나뉘어져 있어, 아키텍처를 이해하고 개발 초기 단계에 설계하기가 쉽다.
하지만 프로젝트 규모가 점점 커지고 복잡해지면서, 개발자들이 지켜야할 규칙을 한 두개 씩 어기다 보면 계층 간 의존성이 얽혀 나중에는 스파게티 코드가 될 수 있다는 단점이 존재한다.
나 또한 이러한 문제를 겪었고 이를 해결하기 위해 헥사고날 아키텍처 사용을 고려했다.
헥사고날 아키텍처
처음 헥사고날 아키텍처를 접했을 때는 완벽한 아키텍처라고 생각해 이 아케틱처를 적용한 프로젝트를 진행하기로 했다.
그렇게 생각한 가장 큰 이유는 내가 겪었던 의존성 문제를 가장 완벽하게 해결할 수 있었다.
내가 겪은 의존성 문제
당시 프로젝트에서는 특정 Member가 작성한 리뷰의 총 개수와 평균 별점을 조회하는 서브쿼리 로직이 필요했다.
이 로직은 원래 Review와 관련된 것이었지만, MemberCustomRepositoryImpl에도 동일한 코드를 중복 작성해야 했다.
또한, 이 로직을 어느 클래스에서 담당해야 하는지가 명확하지 않아, 유지보수 과정에서 어려움을 겪었다.
헥사고날 아키텍처에서의 해결 방법
헥사고날 아키텍처는 핵심 도메인을 중앙에 두어, 비즈니스 로직이 외부 기술이나 다른 도메인에 의존하지 않도록 설계한다.
또한 모든 외부 연동(DB, API 호출 등)은 인터페이스(Port)를 통해 수행하며, 이를 구현하는 어댑터(Adapter)가 실제 동작을 담당합니다.
그리고 인프라 계층이 도메인 계층을 의존하도록 설계하여 의존성 방향을 역전시킴으로써, 도메인이 외부 변화의 영향을 받지 않게 합니다.
특징 정리
- 모든 외부 연동은 인터페이스(port)를 통해 수행한다.
- 인터페이스(port)를 구현하는 어댑터가 실제 동작을 담당한다.
- 인프라스트럭쳐 계층이 도메인 계층을 의존하도록 설계하여 의존성 방향을 역전시킨다.
- 도메인이 외부 변화에 영향을 받지 않게 한다.
고민 사항
실제 프로젝트에서 적용을 하기 위해 팀원과 회의 중 헥사고날 아키텍처를 처음 들은 팀원은 당연히 복잡하고 진입 장벽이 높다고 생각을 했다.
그래서 나는 헥사고날 아키텍처에서 가장 널리 쓰이고 있는 port와 adapter라는 단어를 사용하지 않기로 했다.
대신 레이어드 아키텍처에서 익숙한 용어들을 그대로 사용하면서, 내부적으로는 헥사고날 아키텍처의 원리를 적용했다.



또한 DDD 에서 추구하는 바운디드 컨텍스트 개념도 적용해 각 컨텍스트 별로 독립적으로 운영하고, 서로 다른 컨텍스트 간에는 인터페이스로만 통신하게 설계했다.
프로젝트 아키텍처 설계와 규칙
순수 도메인 객체
JPA와 연관이 없는 순수 도메인 객체와 JPA와 연관된 Entity를 분리
JPA User Entity
package com.gagoo.thiscoding.domain.maria.user.infrastructure;
@Getter
@Entity
@Table(name = "users")
public class UserEntity {
// User와 똑같은 필드
public static UserEntity from(User user) {
// User를 UserEntity로 변환하기 위한 로직
}
public User toModel() {
// UserEntity를 User로 변환하기 위한 로직
}
}
순수 도메인 User
package com.gagoo.thiscoding.domain.maria.user.domain;
@Getter
public class User {
// UserEntity와 똑같은 필드
// 비지니스 로직
}
도메인 규칙을 객체 내부에 비지니스 로직으로 표현이 가능하고, JPA 어노테이션을 완전히 제거했다.
인터페이스(Port) 정의
Inbound Port(서비스 계층 인터페이스)
// controller.port 패키지
public interface UserService {
boolean checkEmailDuplicate(String email);
boolean checkNicknameDuplicate(String nickname);
User updateNickname(UpdateProfileNicknameRequest request);
User updateImage(UpdateProfileImageRequest request);
Page<User> searchByNickname(String nickname, Pageable pageable);
}
public interface CertificationService {
AuthCodeRequest sendJoinCode(String email);
AuthCodeRequest sendTemporaryPassword(String email);
AuthCodeRequest checkAuthCode(AuthCodeRequest request);
}
controller 계층의 port는 외부에서 들어오는 요청을 처리하는 인터페이스로 사용했고,
service 계층의 port는 외부로 나가는 요청(DB, 외부 API)을 처리하는 인터페이스로 사용했다.
OutBound Port(인프라스트럭쳐 인터페이스)
// service.port 패키지
public interface UserRepository {
Optional<User> findById(Long id);
Optional<User> findByEmail(String email);
Optional<User> findByNickname(String nickname);
Page<User> findByNicknameContaining(String nickname, Long myId, Pageable pageable);
User save(User user);
boolean existsByEmail(String email);
boolean existsByNickname(String nickname);
}
// auth 도메인에서 제공하는 port
public interface PasswordEncoderHolder {
String encode(String plainPassword);
}
public interface SecurityUtils {
String getUserEmail();
}
도메인 간 협력 또한 인터페이스로 한다.
클라이언트 요청과 반환
request
포트와 어댑터라는 단어를 쓰지 않고 레이어드 아키텍처에서 익숙한 용어를 사용하여 설게를 했기 때문에
클라이언트의 요청은 controller.request 패키지에서 받게 설계했다.
package com.gagoo.thiscoding.domain.auth.controller.request;
public record LoginRequest(@Email @NotBlank String email, @NotBlank String password) {
}
response
클라이언트에게 전달할 응답 데이터를 정의하는 패키지로 사용했다.
User는 순수한 비즈니스 로직에 집중하고, 데이터의 외부 노출 형식은 UserResponse와 같은 DTO로 반환했다.
또한 API 응답 형식이 변경되더라도 순수 도메인 객체는 영향을 받지 않고, UserResponse 클래스만 수정하면 된다.
package com.gagoo.thiscoding.domain.auth.controller.response;
@Getter
@Builder
public class UserResponse {
// 반환에 필요한 필드
public static UserResponse from(User user) {
return UserResponse.builder()
// 반환에 필요한 필드
.build();
}
}
같은 도메인 내 책임 분리
AuthServiceImpl
컨트롤러 계층의 port의 구현체로 인증에 필요한 비지니스 로직을 담당한다.
동일한 도메인에서 현재 서비스의 책임이 아닌 비지니스 로직은 PasswordService와 같이 port로 분리해 책임을 위임한다.
package com.gagoo.thiscoding.domain.auth.service;
import com.gagoo.thiscoding.domain.auth.controller.port.AuthService;
import com.gagoo.thiscoding.domain.auth.service.port.PasswordService;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final PasswordService passwordService;
/**
* 로그인
*/
@Override
public LoginDto login(LoginRequest request) {
User user = findUserByEmail(request.email());
validateUserActivation(user);
passwordService.matchPassword(request.password(), user.getPassword());
return createLoginDto(user);
}
}
자주 쓰이는 공통 로직 분리
package com.gagoo.thiscoding.domain.maria.user.service.helper;
import com.gagoo.thiscoding.domain.maria.user.service.port.UserRepository;
@Component
@RequiredArgsConstructor
public class UserFinder {
private final UserRepository userRepository;
public User getByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
}
public User getByNickname(String nickname) {
return userRepository.findByNickname(nickname)
.orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
}
}
이번 프로젝트의 커뮤니티 서비스는 회원 기반이므로, 여러 서비스에서 유저 조회 로직이 반복적으로 필요하다.
조회 로직을 UserService에 넣어 호출하면, UserService가 조회 전용인 것처럼 보일 수 있어 책임이 모호해질 수 있다.
이를 해결하기 위해, 공통 조회 로직을 UserFinder라는 헬퍼 클래스로 분리했다.
각 서비스는 UserFinder를 통해 필요한 유저를 간단히 조회하고, 자신의 핵심 비즈니스 로직에만 집중할 수 있다.
이러한 헬퍼 클래스는 변경 가능성이 낮기 때문에 불필요한 추상화 없이 단일 클래스로 구현했다.
마무리
설계와 적용 과정에서 어려웠던 점
초기 개발 속도 저하
인터페이스를 먼저 설계해야 하고 이로인해 클래스가 많아졌으며 의존성의 방향을 생각하며 적용해야 했기 때문에 초기 개발 속도가 느렸다.
과도한 추상화
또한 처음에는 모든 클래스 간의 통신을 인터페이스로 했는데, 이렇게 진행하면 클래스가 너무 많아지고 추상화가 너무 과했다.
그래서 helper 클래스를 도입했고, 이를 통해 어느정도 고민이 해결되긴 했지만 그래도 프로젝트 규모에 비해 너무 과했다.
정답이 없는 아키텍처
널리 쓰이는 방법을 사용하지 않고, 처음부터 쉽게 접근하기 위해 헥사고날과 레이어드를 짬뽕해서 설계했다.
처음 설계는 순탄했지만, 프로젝트를 진행하면서 여러 상황이 발생했고 대표적으로 다음 과 같은 상황이 있었다.
- 같은 도메인 내에서 서로 다른 책임을 가진 객체들 간의 통신
- 여러 서비스에서 공통으로 사용하는 조회 로직을 어느 계층에 둘지
- 도메인 간 데이터 교환 시 어느 정도까지 추상화를 해야 할지
이런 상황들이 발생할 때마다 시원하게 이거다 싶은 해답이 없어서 팀원들과 논의하고 설계를 다시 검토하는데 많은 시간이 소요되었다.
긍정적인 효과
어려웠던 부분도 있었지만 긍정적인 부분도 많았다.
제약조건
처음에는 아키텍처가 단순히 패키지 구조라고 생각했다. 하지만 실제 적용해보니 개발자가 해도 되는 것과 하지 말아야 하는 것을 명확히 구분지어 주는 제약 조건과 같았다. 또한 레이어드 아키텍처의 문제점인 "이정도는 괜찮겠지?" 라는 타협을 원천적으로 차단할 수 있었다.
테스트 용이성
개인적으로 가장 큰 장점이었다고 생각한다.
테스트할 때 외부 라이브러리로부터 완전히 의존성을 차단했기 때문에 비지니스 로직에 대해 테스트가 굉장히 빨랐고, 라이브러리가 변경되도 테스트 코드는 변경하지 않아도 됐다.
하지만 Fake 객체와 Mock 데이터를 만들어줘야 해서, 컨트롤러 계층와 인프라스트럭쳐 계층에 대한 테스트는 완벽하게 하지 못해 아쉬움이 크다.
결론
헥사고날 아키텍처 기반의 프로젝트를 내가 주도적으로 다시 한다면, 레퍼런스가 많은 널리 쓰이는 방식을 사용할 거 같다.
또한 이번 경험으로 깨달은 것은, 헥사고날 아키텍처가 나의 고민을 완벽히 해결해 줄 수 있는 아키텍처가 맞지만, 사실 레이어드 아키텍처로도 나의 고민을 충분히 해결할 수 있다는 점이다.
헥사고날 아키텍처는 제약조건을 많이 걸어 클린 코드를 지향하는 것이라 생각이 들었다. 하지만 레이어드 아키텍처에서도 처음부터 엄격한 제약조건을 설정하고, 타협 없이 일관성 있게 진행하면 비슷한 효과를 줄 수 있다고 느꼈다.
결국 좋은 아키텍처는 복잡한 구조가 아닌 명확한 원칙과 그 원칙을 지키는 것이 중요하다는 것을 배웠다.
'개발' 카테고리의 다른 글
| 재고 차감 시 발생하는 동시성 이슈 해결 - 비관적 락 (1) | 2025.12.21 |
|---|---|
| MSA 환경에서 이벤트 데이터 정합성 문제를 해결하기 위한 아웃박스 패턴 적용 (1) | 2025.12.13 |
| Querydsl 서브쿼리 중복 코드 개선 - 의존성 분리 (2) | 2025.08.11 |
| 통계 데이터 비동기로 관리하기 (2) | 2025.08.01 |
| 개발 환경 통일을 위한 Docker Compose 도입 (5) | 2025.07.30 |