Member 엔티티가 있으니 MemberService 하나, MemberController 하나. 스프링을 배우면 대부분 이 구조로 시작한다. 처음에는 편하다. 문제는 N개월 뒤에 온다. MemberService는 메서드 열다섯 개짜리 클래스가 되고, 그것을 주입받은 코드만 봐서는 무슨 목적으로 쓰는지 알 수 없다. 헥사고날 아키텍처는 이 지점에서 다른 답을 낸다. 애플리케이션의 경계를 나누는 기준은 엔티티도 메서드 개수도 아니라 의도다. Splearn 프로젝트에 회원 인증 포트(MemberAuthenticator)를 추가하는 과정을 따라가면서, 포트를 어떤 기준으로 나누고 무엇을 결정해야 하는지 정리한다
포트는 애플리케이션이 외부와 소통하는 의도다
포트의 정의는 이렇다
포트(Port)란, 애플리케이션이 외부 세계와 어떤 의도를 가지고 상호작용하는 아이디어를 캡처한 것이다.
정의만 보면 추상적이므로 용어를 하나씩 풀어본다
- 애플리케이션(Application): 도메인 관련 기능을 모아 구현하고 외부와 분리해 놓은 영역. 코어(Core) 또는 헥사곤(Hexagon)이라고도 부른다. 육각형 안에 들어있는 것이 애플리케이션이다
- 외부 세계(External World): 육각형 밖에 있는 모든 것. 사용자, 데이터베이스, 메일 서버, 다른 시스템은 물론 테스트 코드까지 전부 외부다
- 의도(Intent): 포트의 핵심. 애플리케이션은 외부와 데이터를 주고받기만 하는 것이 아니라 분명한 목적과 방향을 가지고 상호작용한다. 그 목적과 방향을 표현한 것이 포트다
포트는 헥사곤의 경계선 위에 그려진다. 외부와 애플리케이션이 만나는 접점이기 때문이다
[User] [Test] [다른 컴포넌트]
\ | /
\ | /
┌──────●───●───●──────┐ ← Provided Interface (Lollipop)
│ │
│ Application │
│ (Core) │
│ │
└──────○────────○─────┘ ← Required Interface (Socket)
| |
[Database] [Mail Server]
위쪽 접점과 아래쪽 접점의 화살표 방향이 다르다. 회원을 등록하려는 의도로 외부가 애플리케이션을 사용할 때는 안쪽으로 들어온다. 회원 정보를 저장하려는 의도로 애플리케이션이 DB를 사용할 때는 바깥쪽으로 나간다. 방향이 다르므로 포트의 종류도 두 가지다
| 구분 | Provided Interface | Required Interface |
|---|---|---|
| 별칭 | Driving Port, Inbound Port, Primary Port | Driven Port, Outbound Port, Secondary Port |
| 방향 | 외부 → 애플리케이션 | 애플리케이션 → 외부 |
| 의미 | 애플리케이션이 제공하는 기능 | 애플리케이션이 필요로 하는 기능 |
| 구현 위치 | 애플리케이션 계층 (~Service) | 어댑터 계층 |
| Splearn 패키지 | application.member.provided | application.member.required |
| 예시 | MemberRegister, MemberFinder | MemberRepository, EmailSender |
Splearn은 이 구분을 패키지 이름으로 강제한다. provided / required라는 이름 자체가 포트의 종류를 말해주므로, 파일 위치만 봐도 방향을 알 수 있다. 이 글에서 다루는 것은 애플리케이션이 외부에 제공하는 Provided Interface다
포트를 나누는 기준은 엔티티도 메서드 개수도 아니다
가장 흔한 안티패턴부터 본다. 엔티티 하나에 서비스 하나를 붙이는 구조다
// Member 엔티티가 있으니까 MemberService 하나, MemberController 하나
public interface MemberService {
Member register(...);
Member activate(...);
Member deactivate(...);
Member updateInfo(...);
Member find(...);
Member findByEmail(...);
Member login(...);
void changePassword(...);
void resetPassword(...);
// ... 계속 늘어난다
}
이 인터페이스는 시간이 지나면 세 가지 문제를 만든다. 첫째, 클래스 덩치가 걷잡을 수 없이 커진다. 둘째, MemberService를 주입받은 코드를 봐도 무슨 목적으로 쓰는지 드러나지 않는다. 셋째, 리팩터링이 반드시 필요한 구조가 되는데 그때는 이미 손대기 어렵다
헥사고날 방식은 의도 단위로 쪼갠다
public interface MemberRegister { /* 회원 등록 라이프사이클 */ }
public interface MemberFinder { /* 회원 조회 */ }
public interface MemberAuthenticator { /* 회원 인증 */ }
사용하는 쪽 코드가 달라진다
@RestController
@RequiredArgsConstructor
public class LoginApi {
private final MemberAuthenticator memberAuthenticator; // 인증 목적임이 필드 선언에 드러난다
}
이 클래스가 회원 데이터로 무엇을 하는지, 생성자만 봐도 알 수 있다. 이것이 의도 단위 분리의 실질적인 이득이다
주의할 점은 의도 단위 분리가 메서드 개수 단위 분리와 다르다는 것이다. MemberRegister는 메서드가 네 개다
| 포트 | 의도 | 메서드 |
|---|---|---|
MemberRegister | 회원 등록과 관련된 라이프사이클을 다룬다 | register, activate, deactivate, updateInfo |
MemberFinder | 회원 정보를 조회한다 | find(Long memberId) |
MemberAuthenticator | 회원을 인증한다 | login(MemberLoginRequest) |
register 외에 activate, deactivate, updateInfo가 함께 들어있는 이유는 “회원 등록”이라는 하나의 의도 안에 최초 가입, 등록 완료 처리, 등록 정보 수정, 탈퇴가 모두 포함된다고 봤기 때문이다. 메서드가 넷이라고 넷으로 쪼개지 않는다
이 원칙의 이론적 근거는 SOLID의 ISP(Interface Segregation Principle)다
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
회원 정보를 조회하기만 하는 코드가 등록·탈퇴·인증까지 전부 들어있는 MemberService에 의존하는 것은 ISP 위반이다. 두 가지 의도가 모두 필요하다면 두 개를 주입받는다
@RequiredArgsConstructor
public class SomeService {
private final MemberFinder memberFinder; // 조회가 필요해서
private final MemberRegister memberRegister; // 등록도 필요해서
}
의존성이 두 개로 늘어난 것이 아니라, 원래 두 개였던 의존성이 하나로 뭉쳐 있었을 뿐이다
AI에게 스프링 개발을 맡기면 MemberService 하나가 나온다
Claude Code 같은 도구에 스프링 애플리케이션 개발을 시켜보면, 별다른 지시 없이는 거의 항상 MemberService 하나에 모든 기능을 몰아넣는다. 이유는 명확하다. 모델은 지난 20여 년간 GitHub 등에 공개된 스프링 코드를 학습했고, 거기서 가장 평균적으로 많이 나타나는 패턴을 재현한다. 평균적이라는 것이 좋은 설계라는 뜻은 아니다. 엔티티당 서비스 하나 구조는 흔하지만 헥사고날 아키텍처가 피하려는 바로 그 구조다. 따라서 구체적인 가이드를 줘야 한다
- 헥사고날 아키텍처를 따를 것 - provided/required 패키지 규칙과 네이밍 규칙은 이렇게 할 것 - 인터페이스는 ISP 원칙에 따라 사용하는 쪽의 필요에 맞게 그룹핑해서 분리할 것
이 정도만 명시해도 잘 따라온다. 그냥 두면 포트 구성이 제대로 되지 않는다. Splearn은 이 가이드를 개발가이드.md 문서로 남겨두고 있다. 규칙이 어느 정도 잡히면 문서로 적어두는 편이 좋다. 사람에게도 필요하고, 세션이 바뀌면 기억이 사라지는 AI 도구에는 더 필요하다
포트와 구현 클래스는 1:1일 필요가 없다
포트는 인터페이스다. 그렇다면 구현 클래스는 몇 개를 만들어야 하는가
| 방식 | 언제 적합한가 |
|---|---|
| 인터페이스 1개 : 클래스 1개 | 책임이 명확히 다르거나, 의존하는 컴포넌트가 다를 때 |
| 인터페이스 N개 : 클래스 1개 | 아직 메서드가 적고, 의존하는 컴포넌트가 거의 동일할 때 |
초기 단계에서 메서드가 많지 않고 MemberRepository 하나에만 의존한다면, 클래스 하나가 여러 포트 인터페이스를 구현하도록 시작해도 된다. 덩치가 커지면 그때 분리한다
나중에 쪼개도 안전한 이유가 중요하다. 포트를 사용하는 어댑터나 다른 애플리케이션 컴포넌트는 인터페이스에만 의존하므로, 구현 클래스를 어떻게 쪼개고 합치든 호출하는 쪽 코드는 영향받지 않는다. 인터페이스를 미리 갈라놓는 것이 구현을 미리 갈라놓는 것보다 값이 싸다
같은 이유로 확장에도 유리하다. 멀티 모듈로 분리하거나 마이크로서비스로 떼어낼 때, MemberService 하나에 모든 게 붙어 있으면 코드를 전부 뜯어고쳐야 한다. 포트가 의도별로 나뉘어 있으면 그 경계선을 그대로 모듈 경계로 쓸 수 있다
테스트는 구현 클래스가 아니라 포트에 대해 작성한다
“리팩터링 한 번 했더니 테스트가 왕창 다 깨진다”는 상황은 대부분 구현 클래스를 직접 테스트했기 때문에 생긴다
MemberAuthenticationServiceTest— 구현 클래스 테스트. 클래스를 분리하면 깨진다MemberAuthenticatorTest— 포트 인터페이스 테스트. 계약이 유지되면 안 깨진다
인터페이스를 기준으로 테스트하면 전체 설계가 흔들릴 정도의 변화가 아닌 한 리팩터링에도 테스트를 고칠 일이 거의 없다. 앞 절에서 “구현 클래스를 나중에 쪼개도 된다”고 한 전제가 성립하려면 테스트도 인터페이스를 향해야 한다. 포트를 잘게 나누면 테스트에서 한 가지 이득이 더 있다. Mockito 같은 동적 목 라이브러리는 편리하지만 프록시 생성 비용 때문에 테스트 성능을 떨어뜨린다. 인터페이스에 메서드가 한두 개뿐이라면 인라인 목으로 대체할 수 있다
// 메서드가 하나뿐이면 람다 한 줄로 끝난다 MemberFinder stubFinder = memberId -> MemberFixture.createMember(memberId);
메서드가 열다섯 개인 MemberService로는 이렇게 쓸 수 없다. Splearn의 MemberFixture.createPasswordEncoder()도 같은 방식이다
public static PasswordEncoder createPasswordEncoder() {
return new PasswordEncoder() {
@Override public String encode(String password) {
return password.toUpperCase();
}
@Override public boolean matches(String password, String passwordHash) {
return encode(password).equals(passwordHash);
}
};
}
익명 클래스로 만든 가짜 인코더다. 대문자 변환을 암호화로 취급하므로 실제 해싱 비용이 들지 않고, 테스트에서 기대값을 눈으로 계산할 수 있다
실전 – 회원 인증 포트를 만드는 순서
여기까지가 원칙이고, 이제 실제로 로그인 기능을 포트로 만든다. 인증(Authentication)은 애플리케이션에 접근하려는 액터의 정체를 확인하는 과정이고, 가장 쉬운 구현이 로그인이다
도메인부터 확인한다
새 기능을 추가할 때는 항상 도메인에 수정할 것이 있는지부터 본다. 인증에 필요한 것이 Member에 이미 있는지 확인한다
public class Member extends AbstractEntity {
private Email email;
private String passwordHash; // 비밀번호가 암호화되어 저장됨
private MemberStatus status;
public boolean verifyPassword(String password, PasswordEncoder passwordEncoder) {
return passwordEncoder.matches(password, this.passwordHash); // 비밀번호 확인 기능 존재
}
public boolean isActive() {
return status == MemberStatus.ACTIVE; // 상태 확인 기능 존재
}
}
비밀번호 검증과 상태 확인이 이미 도메인에 있다. 남은 것은 “이메일로 회원이 존재하는가”인데, 이것은 도메인 오브젝트가 직접 할 수 없다. Required 포트를 통해 데이터베이스에 접근해야 하기 때문이다. 애플리케이션 서비스의 책임이다. 결론은 도메인을 수정하지 않는다
포트 인터페이스를 정의한다
package splearn.application.member.provided;
import jakarta.validation.Valid;
import splearn.domain.member.Member;
/**
* 회원 인증
* - ACTIVE 상태인 회원만 로그인할 수 있다
*/
public interface MemberAuthenticator {
Member login(@Valid MemberLoginRequest loginRequest) throws LoginFailedException;
}
이 여섯 줄에 결정이 네 개 들어있다
이름을 Authenticator로 한 이유. 인터페이스 이름은 포트의 의도를 표현하면서 자바 관례에 따라 명사형 또는 형용사형이어야 한다. authenticate(인증하다)를 명사화하면 Authenticator(인증하는 것)다. 기존 포트도 같은 규칙을 따른다. register → Register, find → Finder
주석을 반드시 다는 이유. 헥사고날의 포트는 API다. 어댑터, 다른 애플리케이션 컴포넌트, 테스트를 작성하는 개발자들이 이 인터페이스만 보고 개발한다. 최소한 이 포트가 어떤 의도인지, 사용할 때 신경 써야 할 조건은 무엇인지, 어떤 예외가 던져지는지는 주석에 남긴다. Javadoc(/**)을 쓰는 것이 적절하다. IDE가 호출 지점에서 툴팁으로 띄워주므로 사용하는 쪽이 파일을 열어보지 않아도 조건을 확인할 수 있다
리턴 타입을 Member로 한 이유. 로그인이 성공하면 그 회원 정보로 세션을 만들거나, 토큰을 발급하거나, 응답을 조립하는 후속 작업이 이어진다. 회원 엔티티를 리턴하는 것이 가장 유연하다. 그것을 어떻게 활용할지는 이 포트에 의존하는 쪽이 결정한다
파라미터를 DTO로 한 이유. 당장 필요한 것은 이메일과 비밀번호뿐이라 String 두 개로도 동작한다. 그러나 두 가지를 생각해야 한다. 하나는 파라미터가 늘어날 가능성이다. 운영 레벨로 키우면 비밀번호 실패 재시도 횟수, 소스 IP, 접속 경로, 2단계 인증 코드 등이 붙는다. 다른 하나는 Bean Validation이다. 파라미터에 표준 검증 애노테이션을 걸려면 DTO가 필요하다
요청 DTO를 만든다
package splearn.application.member.provided;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
public record MemberLoginRequest(
@Email String email,
@Size(min = 8, max = 100) String password
) {
}
record로 만들어 불변 DTO를 얻고, MemberRegisterRequest와 동일한 제약을 붙인다
여기서 놓치기 쉬운 관점이 하나 있다. 이 검증은 누구를 위한 것인가. 비밀번호 길이 검증에 걸린다는 것은 “비밀번호가 틀렸다”가 아니라 “클라이언트 쪽 코딩이 잘못됐다”는 뜻이다. 이 포트를 사용하는 개발자가 잘못된 값을 넘겼거나, 프론트엔드가 검증 없이 보냈다는 의미이므로 버그다. 그러니 ConstraintViolationException을 던져 그 개발자에게 알려주는 것이 맞다. 사용자에게 보여줄 메시지로 번역할 대상이 아니다
Bean Validation을 스프링 빈 메서드에서 동작시키려면 두 가지가 함께 있어야 한다
| 위치 | 애노테이션 | 출처 |
|---|---|---|
| 포트 메서드의 파라미터 | @Valid | 표준(Jakarta) |
| 구현 서비스 클래스 | @Validated | Spring |
하나라도 빠지면 검증이 조용히 동작하지 않는다. 실패하는 것이 아니라 그냥 통과한다
실패를 예외로 설계한다
로그인 실패를 어떻게 알릴 것인가. null을 리턴해도 되지만 그렇게 하려면 “null이면 로그인 실패”를 반드시 주석에 명시해야 한다. 여기서는 예외를 선택한다. 일반적으로 예외는 시스템에 문제가 생긴 진짜 예외 상황에 쓰고, 핸들러가 “잠시 후 재시도해 주세요” 같은 대응을 한다. 로그인은 다르다. 정상적인 흐름 안에 성공과 실패라는 분기가 매우 중요하게 자리잡고 있다. 따라서 이를 어떻게 처리하는지 API 시그니처에 드러내야 한다
Member login(@Valid MemberLoginRequest loginRequest) throws LoginFailedException;
이 포트를 쓰려는 사람은 시그니처만 보고 “성공하면 Member, 실패하면 LoginFailedException“을 이해한다. 실패 시 구체적인 작업(실패 횟수 기록, 계정 잠금 안내)이 필요하면 catch하고, 상관없으면 @ControllerAdvice로 흘려보낸다
예외는 unchecked여야 한다. Exception을 상속하면 checked exception이 되어 호출하는 모든 쪽이 강제로 try-catch를 해야 한다. 잡을지 말지는 사용하는 쪽이 선택할 문제다
package kimspring.splearn.application.member.provided;
public class LoginFailedException extends RuntimeException {
}
RuntimeException이므로 시그니처의 throws는 컴파일러 관점에서 불필요하다. 의도적으로 남겨둔 것은 문서화 목적이다. 포트는 API이고, API 사용자에게 실패 계약을 알려주는 것이 throws 절의 역할이다. Javadoc에 @throws를 함께 적으면 의도가 더 분명해진다
예외를 어디에 둘지는 정답이 하나가 아니다. DuplicateEmailException, DuplicateProfileException은 도메인 패키지에 넣었다. “회원의 이메일은 중복될 수 없다”가 도메인 규칙 그 자체이기 때문이다. 로그인 실패는 갈린다
| 도메인에 두자는 관점 | 포트에 두자는 관점 |
|---|---|
| “이메일과 비밀번호가 맞지 않으면 로그인이 실패한다”도 도메인 지식이다 | 로그인은 절차적인 작업이고, 그 절차 수행 중 발생한 실패다 |
| 도메인 언어로 그렇게 이야기된다면 도메인 모델로 만드는 게 맞다 | Member 오브젝트가 던지는 예외가 아니다 |
Splearn은 후자를 택해 provided 패키지에 두었다. 계층 의존 관계상 문제가 없고(애플리케이션 → 도메인 방향 유지), 이 예외가 던져지는 곳이 이 포트이기 때문이다. 도메인에 두는 선택도 충분히 타당하다
예외에 메시지를 담지 않는다. 이것은 취향 문제가 아니라 보안 문제다.
// 이렇게 하지 않는다
throw new LoginFailedException("해당 이메일의 회원이 존재하지 않습니다");
throw new LoginFailedException("비밀번호가 일치하지 않습니다");
실패 이유가 “그 이메일의 회원이 없어서”인지 “비밀번호가 틀려서”인지 알려주면, 공격자가 이를 계정 존재 여부를 확인하는 수단(User Enumeration)으로 쓸 수 있다. 이메일 목록을 대입해 어느 주소가 가입되어 있는지 추려낸 다음 그 계정만 집중적으로 공격하는 것이 가능해진다. 모든 실패를 구분 없이 하나의 예외로 처리하고, 사용자에게는 “입력하신 정보로는 로그인할 수 없습니다” 정도만 전달한다. 불친절해 보이지만 이것이 안전하다
관련해서 원문에서 다루지 않은 부분을 덧붙이면, 메시지를 없애는 것만으로 User Enumeration이 완전히 막히지는 않는다. 회원이 없으면 비밀번호 해시 검증을 건너뛰므로 응답이 빠르고, 회원이 있으면 해시 비교를 하므로 느리다. 이 시간 차가 그대로 정보다. 로그인 공격이 신경 쓰이는 서비스라면 회원을 못 찾았을 때도 더미 해시로 검증을 한 번 돌려 응답 시간을 맞추는 방식을 검토할 만하다
패키지 구성을 정한다
provided 패키지에는 세 종류가 섞이게 된다
application/member/provided/ ├── MemberRegister.java ← 포트 인터페이스 ├── MemberFinder.java ← 포트 인터페이스 ├── MemberAuthenticator.java ← 포트 인터페이스 ├── MemberRegisterRequest.java ← DTO ├── MemberLoginRequest.java ← DTO └── LoginFailedException.java ← Exception
선택지는 둘이다. 전부 provided에 함께 두거나, provided/dto와 provided/exception으로 분리해 provided 밑에는 인터페이스만 남기는 것이다. Splearn은 전자를 택했다. 도메인 패키지도 엔티티, 값 객체, 예외를 domain.member 안에 함께 두었기 때문이다. 결국 다 도메인 모델을 구성하는 요소라는 관점이고, 포트도 같은 관점으로 본 것이다. 개수가 많지 않으니 아직 문제가 없고, 나중에 문제가 보이면 그때 옮겨도 된다. 여기서 중요한 것은 어느 쪽을 택했느냐가 아니라 일관성이다. 멤버 쪽을 이렇게 했다면 다른 컴포넌트도 같은 룰을 따라야 한다. 규칙이 잡히면 개발 가이드 문서에 적어둔다
구현 클래스를 분리한다
기존 서비스 클래스에 인터페이스를 추가로 구현할 수도 있었다
| 후보 | 판단 |
|---|---|
MemberModifyService (변경) | 로그인은 “변경” 작업이 아니다 |
MemberQueryService (조회) | 단순 조회로 보기 어렵다. 로그인 시 이력 기록 등이 붙는다 |
| 새 클래스 | 선택 |
User, Member, Account 같은 가장 기본이 되는 도메인은 시간이 지나면 반드시 복잡해진다. 처음부터 한곳에 모아두는 것은 좋지 않다
package splearn.application.member;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import jakarta.transaction.Transactional;
import splearn.application.member.provided.LoginFailedException;
import splearn.application.member.provided.MemberAuthenticator;
import splearn.application.member.provided.MemberLoginRequest;
import splearn.application.member.required.MemberRepository;
import splearn.domain.member.Member;
import splearn.domain.member.PasswordEncoder;
import splearn.domain.shared.Email;
import lombok.RequiredArgsConstructor;
@Service
@Transactional
@Validated
@RequiredArgsConstructor
public class MemberAuthenticationService implements MemberAuthenticator {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public Member login(MemberLoginRequest loginRequest) throws LoginFailedException {
Member member = memberRepository.findByEmail(new Email(loginRequest.email()))
.orElseThrow(LoginFailedException::new);
if (!member.isActive()) {
throw new LoginFailedException();
}
if (!member.verifyPassword(loginRequest.password(), passwordEncoder)) {
throw new LoginFailedException();
}
return member;
}
}
애노테이션 네 개는 Splearn의 모든 애플리케이션 서비스가 공유하는 조합이다. @Service는 빈 등록, @Transactional은 트랜잭션 경계, @Validated는 @Valid 파라미터 검증 활성화, @RequiredArgsConstructor는 final 필드 생성자 주입이다. 항상 같이 나오므로 나중에 메타 애노테이션 하나로 묶어도 된다
구현 코드에서 가장 중요한 설계 판단은 왜 MemberFinder가 아니라 MemberRepository를 주입받았는가다. MemberModifyService는 자기 애플리케이션의 포트를 다시 사용한다
// MemberModifyService
private final MemberFinder memberFinder;
public Member activate(Long memberId) {
Member member = memberFinder.find(memberId); // 자기 애플리케이션의 포트를 다시 사용
member.activate();
return memberRepository.save(member);
}
애플리케이션이 자기 포트를 통해 스스로 만든 기능을 쓰는 것은 좋은 패턴이다. “회원을 가져오는데 이 회원은 반드시 존재해야 한다”는 가정과 예외 처리를 한 곳에 모을 수 있다
그렇다면 로그인도 MemberFinder에 findByEmail을 추가해서 쓰면 되지 않을까. 세 가지가 다르다
| 항목 | MemberFinder.find(id) | 로그인의 이메일 조회 |
|---|---|---|
| 못 찾았을 때 | IllegalArgumentException | LoginFailedException이어야 함 |
| 의도 | 일반적인 회원 조회 | 로그인 절차의 일부 |
| 외부 노출 필요성 | 있음 | 없음 |
이 조회는 로그인 절차에 속한 검색이므로 포트로 노출하지 않고 내부에서 MemberRepository를 직접 사용한다. 포트로 올려버리면 로그인 이외의 용도로 이메일 조회를 하는 코드가 생기고, 그때부터 예외 타입 계약이 흐려진다. PasswordEncoder는 도메인에 인터페이스가 있고 adapter/security에 구현이 있다. 보안 기술이 바뀌면 구현만 교체하면 되므로 인터페이스에만 의존한다
로직 흐름은 세 개의 게이트다
login(loginRequest)
└→ memberRepository.findByEmail(new Email(...))
├ 회원 없음 → LoginFailedException
└ 회원 있음
├ isActive() == false → LoginFailedException
└ verifyPassword() == false → LoginFailedException
└ 통과 → return member
세 갈래가 모두 같은 예외로 수렴하는 것이 이 흐름의 핵심이다. 앞에서 이야기한 User Enumeration 방지가 코드 구조로 표현된 셈이다
세부 사항 두 가지가 있다. new Email(...)은 리포지토리부터는 도메인 값 객체 타입으로 전달한다는 규칙이다. DTO의 String email을 그대로 넘기지 않는다. orElseThrow(LoginFailedException::new)는 Optional을 우리가 정의한 예외로 바꾸는 부분이고, 표준 예외를 쓰지 않는다. 단순 호출 하나이므로 메서드 레퍼런스로 썼다. 나중에 로그인 이력 기록이나 최종 로그인 시각 갱신이 필요하면 return member; 직전이 그 자리다
테스트를 인터페이스에 대해 작성한다
테스트 클래스는 구현이 아니라 포트를 향하고, 위치도 포트 패키지를 미러링한다
package splearn.application.member.provided;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import jakarta.transaction.Transactional;
import splearn.SplearnTestConfiguration;
import splearn.domain.member.MemberFixture;
@SpringBootTest
@Transactional
@Import(SplearnTestConfiguration.class)
class MemberAuthenticatorTest {
@Autowired
private MemberAuthenticator memberAuthenticator;
@Autowired
private MemberRegister memberRegister;
@Test
void login() {
var registerRequest = MemberFixture.createMemberRegisterRequest();
memberRegister.register(registerRequest).activate();
var member = memberAuthenticator.login(
new MemberLoginRequest(registerRequest.email(), registerRequest.password()));
}
@Test
void loginFailedNotActive() {
var registerRequest = MemberFixture.createMemberRegisterRequest();
memberRegister.register(registerRequest); // activate() 하지 않음
Assertions.assertThatThrownBy(
() -> memberAuthenticator.login(
new MemberLoginRequest(registerRequest.email(), registerRequest.password())))
.isInstanceOf(LoginFailedException.class);
}
@Test
void loginFailedEmailNotExist() {
var registerRequest = MemberFixture.createMemberRegisterRequest();
memberRegister.register(registerRequest).activate();
Assertions.assertThatThrownBy(
() -> memberAuthenticator.login(
new MemberLoginRequest("notexist@email.com", registerRequest.password())))
.isInstanceOf(LoginFailedException.class);
}
@Test
void loginFailedWrongPassword() {
var registerRequest = MemberFixture.createMemberRegisterRequest();
memberRegister.register(registerRequest).activate();
Assertions.assertThatThrownBy(
() -> memberAuthenticator.login(
new MemberLoginRequest(registerRequest.email(), "wrongpassword")))
.isInstanceOf(LoginFailedException.class);
}
}
테스트 구성상의 선택 몇 가지를 짚는다. @Autowired 필드 주입과 record + 생성자 주입 방식이 모두 유효하다. 기존 MemberFinderTest는 record 스타일을 쓰고 여기서는 필드 방식을 썼다. 테스트 클래스에 public은 필요 없다. JUnit 5는 package-private 클래스도 실행한다. EntityManager로 flush() / clear()를 하지 않는 이유는 이메일 조회가 영속성 컨텍스트 1차 캐시에서 해결되지 않고 반드시 DB로 쿼리가 나가기 때문이다. findById와 다른 지점이다. 그리고 테스트 데이터를 리포지토리에 직접 저장하지 않고 이미 검증을 마친 MemberRegister 포트를 통해 등록한다
테스트를 쓰다가 조건 누락을 발견했다. 처음 성공 케이스는 이랬다
@Test
void login() {
var registerRequest = MemberFixture.createMemberRegisterRequest();
memberRegister.register(registerRequest); // activate() 없음
var member = memberAuthenticator.login(...); // 그런데 성공한다
}
테스트는 통과했다. 그런데 register()만 하면 회원 상태는 PENDING이다. 등록 완료 처리를 기다리는 상태인데 로그인이 되어버린다. 사실 실패해야 하는 로그인이다. “ACTIVE 상태인 회원만 로그인할 수 있다”는 조건을 빠뜨렸다는 것을 테스트가 찾아낸 것이다. 그래서 서비스에 검사를 추가하고, 성공 테스트에는 .activate()를 붙이고, 곧바로 실패 케이스 테스트를 추가했다
케이스는 네 개다
| # | 테스트 | 시나리오 | 기대 결과 |
|---|---|---|---|
| 1 | login | 등록 + activate 후 올바른 이메일/비밀번호 | Member 리턴 |
| 2 | loginFailedNotActive | 등록만 하고 activate 안 함 (PENDING) | LoginFailedException |
| 3 | loginFailedEmailNotExist | 존재하지 않는 이메일 | LoginFailedException |
| 4 | loginFailedWrongPassword | 올바른 이메일 + 틀린 비밀번호 | LoginFailedException |
성공 케이스를 먼저 검증하는 순서가 중요하다. LoginFailedException만 봐서는 어떤 이유로 실패했는지 알 수 없다. 앞에서 보안을 위해 실패 이유를 지웠기 때문에 테스트도 이유를 볼 수 없다. 그래서 순서로 해결한다. 먼저 성공 케이스로 “이 조합이면 성공한다”를 보증한다. 그 상태에서 한 가지 조건만 바꿔 실패하는지 확인한다. 그러면 바로 그 조건 때문에 실패했다는 것이 확정된다
예를 들어 loginFailedEmailNotExist에서 .activate()를 빼먹어도 테스트는 통과한다. 하지만 그러면 실패 원인이 이메일 때문인지 상태 때문인지 구분할 수 없다. .activate()를 넣어두었기 때문에 이메일만이 유일한 변수가 된다
함정이 하나 있다. 비밀번호 불일치 테스트에 "0000" 같은 짧은 값을 쓰면 이렇게 된다
new MemberLoginRequest(registerRequest.email(), "0000") // → ConstraintViolationException 발생 (@Size(min = 8) 위반) // → LoginFailedException 이 아니다
@Valid + @Validated로 걸어둔 길이 검증에 먼저 걸린다. 자릿수 때문에 나는 에러는 여기서 테스트할 대상이 아니므로 길이 제약을 통과하는 값을 쓴다
new MemberLoginRequest(registerRequest.email(), "wrongpassword") // 13자
그리고 assertThatThrownBy를 쓸 때 IDE가 JUnit의 Assertions를 임포트할 수 있다. AssertJ의 org.assertj.core.api.Assertions를 가져와야 한다
작업을 시작하거나 끝낼 때는 항상 전체 테스트를 돌린다. 기존 코드를 건드리지 않았더라도 여러 이유로 실패할 수 있다.
./gradlew test
Splearn은 여기에 HexagonalArchitectureTest(ArchUnit)와 SpotBugs 정적 분석이 함께 걸려 있다. 계층 의존성을 위반하면 빌드가 깨진다. 포트 규칙을 문서로만 두지 않고 테스트로 강제한 것이다
최신 기준 보정
코드를 공식 문서와 대조하면서 확인한 두 가지가 있다
첫째, @Valid는 인터페이스에만 붙여야 한다. 위 코드에서 @Valid가 포트 인터페이스에만 있고 구현 클래스의 login() 오버라이드에는 없다. 이것은 스타일 선택이 아니라 스펙 요구사항이다. Jakarta Validation 스펙은 상속 계층에서 파라미터 제약을 재선언하는 것을 금지한다. 서브타입이 전제조건을 강화하면 behavioral subtyping이 깨지기 때문이다. 구현 클래스의 오버라이드 메서드에 @Valid나 파라미터 제약을 추가로 붙이면 ConstraintDeclarationException이 발생한다. 인터페이스에 선언하고 구현에는 붙이지 않는 것이 유일하게 맞는 방식이다. 참고로 이 금지는 파라미터 제약에만 적용되고 리턴값 제약은 서브타입에서 추가할 수 있다
둘째, @Validated가 만드는 프록시 타입을 알아둘 만하다. @Validated는 AOP 프록시 기반으로 동작한다. 순수 스프링 프레임워크 규칙만 보면 인터페이스를 구현한 빈에는 JDK 동적 프록시가 생성되고, 그러면 구현 클래스 타입으로는 주입받을 수 없다. 그런데 Spring Boot는 spring.aop.proxy-target-class 기본값이 true이고 ValidationAutoConfiguration이 이 값을 MethodValidationPostProcessor에 그대로 넘긴다. 결과적으로 Spring Boot 3.x에서는 인터페이스를 구현한 @Validated 빈도 CGLIB 프록시가 되어 구현 클래스로 주입해도 컨텍스트가 올라간다
그래도 다른 서비스에서는 포트 인터페이스로 주입해야 한다. 이유가 프록시 타입 때문이 아니라 설계 때문이라는 점이 중요하다. 구현 클래스 타입에 의존하면 앞에서 이야기한 “구현을 나중에 쪼개도 안전하다”는 성질이 사라진다. Boot 설정 기본값 하나에 기대는 코드가 되는 것도 좋지 않다
이 방식이 통하지 않는 조건
이 구조가 항상 이득인 것은 아니다
포트를 의도 단위로 나누면 파일 수가 늘어난다. CRUD 한 벌로 끝나는 관리자 화면이나 며칠 만에 검증하고 버릴 프로토타입에서는 인터페이스와 DTO와 예외를 나눠 만드는 비용이 얻는 것보다 크다. 이 방식은 도메인이 계속 자란다는 전제 아래에서 값을 한다
테스트 예제도 그렇다. MemberAuthenticatorTest는 @SpringBootTest로 컨텍스트 전체를 띄운다. 포트 인터페이스에 대해 테스트한다는 원칙과 컨텍스트를 띄운다는 선택은 별개다. 포트가 잘게 나뉘어 있으면 인라인 목으로 컨텍스트 없이 테스트할 수도 있는데, 이 예제는 리포지토리 조회와 트랜잭션 동작까지 확인하려고 통합 테스트를 택했다. 클래스 수가 늘면 이런 테스트가 빌드 시간을 잡아먹으므로, 어디까지 통합 테스트로 갈지는 별도로 판단해야 한다
정리
정리하면 포트 설계의 판단 기준은 하나로 압축된다. 새 기능을 추가할 때 “이것은 어떤 엔티티에 대한 작업인가”를 묻지 말고 “이것은 외부와 어떤 의도로 소통하는 일인가”를 묻는다. 답이 기존 포트의 의도에 담기면 그 포트에 메서드를 추가하고, 다른 의도라면 새 포트를 만든다. 그렇게 나눈 인터페이스에 조건을 적고 그 인터페이스에 대해 테스트를 쓰면, 구현을 어떻게 쪼개고 합치든 호출하는 쪽과 테스트가 흔들리지 않는다
출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2