
흠... 최대한 잘 표현한 것 같다.
단순하게 "회원가입" 하나 만들려고 했을 뿐인데,
어댑터 / 포트 / 서비스 / 매퍼 / 엔티티 …
클래스와 패키지가 끝도 없이 늘어났다.
왜 이렇게까지 쪼개야 할까? 🤔
이 글은, 헥사고날 아키텍처(Ports & Adapters)를
"회원가입" 기능 하나로 작게 적용한 후, 시행착오와 생각을 정리한 기록이다.
com.{프로젝트팀명}.{프로젝트명}.user
├─ adapter
│ ├─ in
│ │ └─ web
│ │ ├─ dto
│ │ │ └─ request
│ │ │ └─ UserSignReqDto.java
│ │ └─ UserController.java
│ └─ out
│ └─ persistence
│ ├─ entity
│ │ └─ UserJpaEntity.java
│ ├─ mapper
│ │ └─ UserMapper.java
│ ├─ repository
│ │ ├─ UserRepository.java // Spring Data JPA
│ │ ├─ UserQuerydslRepository.java // 선택(쿼리dsl 인터페이스)
│ │ └─ UserQuerydslRepositoryImpl.java // 선택(구현)
│ └─ UserJpaRepositoryAdapter.java // ← Port(out) 구현체
│
├─ application
│ ├─ port
│ │ ├─ in
│ │ │ ├─ command
│ │ │ │ └─ CreateUser.java
│ │ │ ├─ response
│ │ │ │ └─ CreateUserResponse.java
│ │ │ └─ CreateUserUseCase.java // in-port (유스케이스 계약)
│ │ └─ out
│ │ └─ UserRepositoryPort.java // out-port (영속성 계약)
│ └─ CreateUserService.java // 유스케이스 구현 (@Service)
│
└─ domain
├─ entity
│ └─ User.java
├─ vo
│ ├─ UserNo.java
│ ├─ LoginInfo.java
│ ├─ Nickname.java
│ ├─ Email.java
│ └─ SocialInfo.java
└─ enums
└─ SocialType.java
외부 요청을 애플리케이션이 이해할 수 있는 형태로 변환
Adapter라는 구현체는 도메인과 외부 세계를 연결하는 구현체다.
외부에서 들어오는 요청을 받아 도메인으로 전달하는 역할하는 클래스를 In 패키지 안에 넣어놨다.
public record UserSignReqDto(
String nickName,
SocialType socialType,
String loginId,
String password,
String email
) {
public CreateUserCommand toCommand() {
return new CreateUserCommand(
new Nickname(nickName), new SocialInfo(socialType, null), new LoginInfo(loginId, password), new Email(email)
);
}
}
@RestController
@AllArgsConstructor
@Tag(name = "User API", description = "유저 관련 API")
@RequestMapping("/api/user")
public class UserController {
private final CreateUserUseCase createUserUseCase;
@PostMapping("/signup")
@Operation(summary = "회원가입", description = "일반회원은 Type이 NONE, 나머지는 알맞게 작성해주세요. (대문자로)")
public ResponseEntity<?> signUp(@Valid @RequestBody UserSignReqDto dto) {
var response = createUserUseCase.createUser(dto.toCommand());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
계층형 아키텍처랑 크게 다른건 없다.
일단 외부에서 DTO를 통해 받는건 똑같다.
사용자가 "회원가입 할게!" 라고 애플리케이션 외부에서 요청을 했으니,
Adapter - In에 넣었다.
도메인 또는 애플리케이션 서비스가 외부에서 호출되는 구현체
'비즈니스 로직'이 시작되는 Application
public record CreateUser(
Nickname nickname,
SocialInfo socialInfo,
LoginInfo loginInfo,
Email email) {
}
Command
외부에서 전달된 데이터를 캡슐화 후,
특정 Use Case 또는 Service 에서 처리할 수 있는 형태로 변환하는 역할
(일반적으로 '무엇을 CRUD 요청한다.' 또는 '무엇을 XX한다.'의 형태)
"근데, DTO를 그냥 다이렉트로 Application에 쓰면 되는거 아냐...? 🤔"
[LINE 테크블로그] 지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기
포스팅 되어 있는 글에 이런 내용이 있다.


Application 포트가 Web 전용 DTO를 받도록 설계돼 있으면,
Application과 Adapter가 강하게 결합되어 있는 상태다.
즉, 이는 헥사고날의 장점을 무너뜨리는 행위다.
게다가, 만약 외부 사정에 의해 DTO를 변경해야 된다면?
애플리케이션 역시 변경해야되는 대참사가 벌어진다.
그래서 Command 클래스로
Applicatio의 DTO를 만들어준다고 생각한다.
게다가, Command는 도메인에 가까운 계층이기 때문에
String보다 의미 있는 타입(VO)를 쓰는 게 더 도메인 친화적이라고 생각한다.
public interface CreateUserUseCase {
CreateUserResponse createUser(CreateUser command);
}
@Transactional
@RequiredArgsConstructor
@Service
public class CreateUserService implements CreateUserUseCase {
private final UserRepositoryPort userRepositoryPort;
@Override
public CreateUserResponse createUser(CreateUser command) {
User user = new User().create(
command.socialInfo(),
command.loginInfo(),
command.nickname(),
command.email()
);
// DB 저장
userRepositoryPort.save(user);
// 응답 DTO 변환
return CreateUserResponse.success();
}
}
아직도 의문인 점이 하나 있다.
User user = new User().create(
command.socialInfo(),
command.loginInfo(),
command.nickname(),
command.email()
);
Command를 도메인으로 바꿔주는 로직이다.
물론 create()는 도메인에 정의를 해둔 상태인데, 이걸 여기서 변환하는게 맞나...?
Application은 UseCase를 조립해서 흐름을 완성하는 곳이다. (Orchestration)
"외부 입력(Command)을 받아 domain을 만들어 규칙을 실행한 뒤, 저장소(Port)를 호출"하는 책임이 여기에 있기 때문이다.
"domain에 비즈니스 로직을 선언"하는게 DDD의 원칙이다.
command를 domain으로 바꾸는건 비즈니스 로직이 아니지 않는가?
그래서 Service에 command → domain 바꾸는 로직을 넣었다.
Application에서 외부 시스템(DB, 메시징, 외부 API 등)에 접근할 필요가 있을 때 정의하는 계약
간단하게 말해, "애플리케이션이 외부로 나가는 통로"
public interface UserRepositoryPort {
void save(User user);
}
Application → Out Port 인터페이스를 실제로 구현
Adapter - In은 외부에서 들어온 요청(Http, RPC, 이벤트 수신 등)
Adapter - Out은 외부 시스템과 통신해야 할 때, 포트를 구현하는 것을 말한다.
• DB 저장소 (JPA Repository)
• 외부 API 호출 (FeignClient, WebClient)
• 메시지 발행 (Kafka Producer, SQS Publisher)
• 파일 시스템, 캐시(Redis)
이러한 것들처럼 "나가는 요청"을 담당하는 부분이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "user")
public class UserJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Embedded
private UserNo externalId;
/*@JsonIgnore*/
// @OneToOne(/*fetch = FetchType.LAZY, */cascade = CascadeType.PERSIST)
// @JoinColumn(name = "profile_music_id")
// private ProfileMusic profileMusic;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "nickname", unique = true, nullable = false))
private Nickname nickname;
@Embedded
private SocialInfo socialInfo;
@Embedded
private LoginInfo loginInfo;
@Embedded
private Email email;
@Embedded
private TimeMetadata timeMetadata;
@Builder
public UserJpaEntity(UserNo externalId, Nickname nickname, SocialInfo socialInfo, LoginInfo loginInfo, Email email) {
this.externalId = externalId;
this.nickname = nickname;
this.socialInfo = socialInfo;
this.loginInfo = loginInfo;
this.email = email;
//this.profileMusic = new ProfileMusic(null);
}
}
@NoArgsConstructor
public final class UserMapper {
// 도메인 → JPA (신규 생성용)
public static UserJpaEntity toJpa(User domain) {
return new UserJpaEntity(
domain.getId(),
domain.getNickname(),
domain.getSocialInfo(),
domain.getLoginInfo(),
domain.getEmail()
);
}
// JPA → 도메인 (복원용)
public static User toDomain(UserJpaEntity e) {
return new User(
e.getExternalId(),
e.getSocialInfo(),
e.getLoginInfo(),
e.getNickname(),
e.getEmail(),
e.getTimeMetadata()
);
}
}
@Repository
@RequiredArgsConstructor
public class UserJpaRepositoryAdapter implements UserRepositoryPort {
private final UserRepository repository;
@Override
@Transactional
public void save(User user) {
try {
// User 엔티티를 UserJpaEntity로 변환
UserJpaEntity userJpaEntity = UserMapper.toJpa(user);
// 변환된 엔티티를 JPA 리포지토리에 저장
repository.save(userJpaEntity);
} catch (DataAccessException e) {
throw new RuntimeException("변환 에러: " + e.getMessage(), e);
} catch (Exception e) {
throw new RuntimeException("알 수 없는 에러: " + e.getMessage(), e);
}
}
}
Mapper를 통해 Domain Entity와 JPA Entity를 변환하는 로직을 작성했다.
왜 분리했는지는 [DDD] 도메인 Entity vs JPA Entity 요기에!