기능 요구 사항
: 서비스에서는 카카오 소셜 로그인을 제공합니다.
비기능 요구 사항
- DDD 아키텍처를 적용합니다.
- 도메인 모델(POJO Domain)과 데이터 모델(JPA Entity)로 분리합니다.
- 인증 데이터는 MemberAccount 도메인으로 관리합니다. => Aggregate Root
- 소셜 로그인과 관련된 데이터는 SocialAccount 도메인으로 통합 관리합니다. => Aggregate Root
- 일반 사용자 데이터는 UserProfile 도메인으로 관리합니다. => Aggregate Root
- MemberAccount와 SocialAccount는 1:N 관계를 가집니다.
- UserProfile와 MemberAccount는 1:1 관계를 가집니다.
- MemberAccount 도메인과 SocialAccount 도메인은 객체 참조 대신 ID 참조 방식을 사용합니다.
- UserProfile 도메인과 MemberAccount 도메인은 객체 참조 대신 ID 참조 방식을 사용합니다.
- 정상적으로 로그인 되어진 사용자는 access token과 refresh token을 발급합니다.
개발을 하다보면 도메인별로 데이터를 관리하고 저장하는 요구사항이 생길 수 있습니다.
이 문제를 해결하기 위한 첫 번째 방법으로는 절차적 기반 처리 방식을 도입하는 것입니다.
[Create]: 절차적 처리 방식의 로직 플로우
- 사용자가 소셜 로그인을 시도하면, 소셜 로그인 서비스에 요청을 보냅니다.
- 소셜 로그인 서비스는 사용자 정보를 반환하고 필요한 데이터를 VO 형태로 변환합니다.
- VO 형태로 변환된 내부의 고유한 값을 통해 SocialAccount 연결된 DB를 조회합니다.
- 조회된 데이터가 있다면 해당 데이터를 사용하고, 없다면 새로운 데이터를 생성하여 토큰을 발급합니다.
[Package]: 절차적 처리 방식의 패키지 구조 예시
-
src/ └── modules/ ├── account └── profile
[Create]: 절차적 처리 방식의 로직 예시 코드
-
@Transactional public PairTokenResponse login(KakaoSocialLoginCommand command) { SocialUserProfileResult profile = loadKakaoUserProfilePort.load(command.authorizationCode()); SocialAccount socialAccount = querySocialAccountService.findExistingSocialAccount(command.provider(), Long.valueOf(profile.providerId())) .orElse(null); MemberAccount memberAccount; if (socialAccount != null) { // [Case A] 기존 가입자: 안전하게 기존 객체의 필드 접근 memberAccount = queryMemberAccountService.findById(socialAccount.getMemberAccountId()); } else { // [Case B] 신규 가입자: 새로운 객체 생성 // B-1. 인증 객체 생성 memberAccount = MemberAccount.create(profile.email(), MemberRole.USER); commandMemberAccountService.save(memberAccount); // B-2. 소셜 계정 객체 생성 SocialUserProfileInfo socialUserProfileInfo = SocialUserProfileInfo.create( profile.providerId(), profile.connectedAt(), profile.emailAgreed(), profile.profileNicknameAgreed(), profile.profileImageUrl(), profile.profileThumbnailImageUrl() ); SocialAccount createSocialAccount = SocialAccount.create(memberAccount.getId(), socialUserProfileInfo); commandSocialAccountService.save(createSocialAccount); // B-3. 사용자 프로필 객체 생성 UserProfileInfo userProfileInfo = UserProfileInfo.create(profile.nickname()); UserProfile userProfile = UserProfile.create(memberAccount.getId(), userProfileInfo); commandUserProfileService.save(userProfile); } TokenClaimsRequest claimsRequest = commandTokenService.createTokenClaimsRequest(memberAccount.getId(), memberAccount.getEmail(), memberAccount.getRole()); return commandTokenService.issueTokenPair(claimsRequest); }
작은 프로젝트 규모이거나 MVP를 개발하는 경우에는 절차적 처리 방식이 효과적일 수 있습니다.
하지만, 규모가 커지고 복잡해지면 절차적 처리 방식은 유지보수가 어려워질 수 있으며 도메인 모듈 간의 물리적/논리적 결합(Coupling)이 높아질 수 있습니다. 이 문제를 해결하기 위한 두 번째 방법으로는 이벤트 기반 처리 방식을 도입하는 것이 있습니다.
: login()로직은 account모듈에 속합니다. 이때, profile모듈에 속하는 loadUserProfilePort를 호출하는 것은 모듈간 논리적 결합이 높아지게 됩니다.
-------------------------------------------------------------------------
논리적 결합도: A 모듈이 B 모듈의 '행위(Service, Repository)'를 직접 호출하는 것.
물리적 결합도: A 모듈이 B 모듈의 '클래스(Class, Record)'를 import 문으로 가져다 쓰는 것.
STEP 1. 이벤트 기반 아키텍처(EDA)란 무엇인가?
EDA는 이벤트를 중심으로 동작하는 아키텍처입니다. 핵심 개념은 아래와 같습니다.
| 개념 | 설명 |
|---|---|
| 이벤트(Event) | 시스템 내에서 발생하는 사건 |
| 이벤트 발행자(Publisher) | 이벤트를 생성하는 주체 |
| 이벤트 구독자(Consumer) | 이벤트를 소비하는 주체, 이벤트가 발생하면 이를 처리하는 주체 |
하나의 기능을 만드는 목표는 절차적 처리 방식과 EDA 모두 동일하지만, EDA는 이벤트를 발행하고 구독하여 핵심 비즈니스 로직 내부의 모듈간 강결합(Coupling)을 낮추는 것이 큰 차이점입니다.
STEP 1-1. 절차적 처리 방식의 한계
절차적 처리 방식으로 개발을 하면 SOLID 원칙의 OCP 위배 및 모듈간 논리적 결합이 높아질 확률이 있습니다.
: 새로운 기능을 추가 할 때, 기존의 핵심 비즈니스의 코드(UseCase 또는 Service) 수정은 최소화하고 확장(Extension)을 통해 해결 할 수 있는가?에 있으며 변경의 파급 효과를 격리하는 것이 주된 목적입니다.
[OCP 위배 및 논리적 강결함 증가 시나리오]: 회원가입 된 일반 사용자는 "이메일로 회원가입 축하 메시지"를 받습니다.
- 기능 요구사항 개발 된 이후에 요구사항이 추가되었다고 가정합니다.
- notification 모듈 내부에 MessageService.sendWelcomeEmail() 함수를 구현했다고 가정합니다.
절차적 처리 방식 구현 코드 예시
@Transactional
public PairTokenResponse login(KakaoSocialLoginCommand command) {
SocialUserProfileResult profile = loadKakaoUserProfilePort.load(command.authorizationCode());
SocialAccount socialAccount = querySocialAccountService.findExistingSocialAccount(command.provider(), Long.valueOf(profile.providerId()))
.orElse(null);
MemberAccount memberAccount;
if (socialAccount != null) {
// [Case A] 기존 가입자: 안전하게 기존 객체의 필드 접근
memberAccount = queryMemberAccountService.findById(socialAccount.getMemberAccountId());
} else {
// [Case B] 신규 가입자: 새로운 객체 생성
// B-1. 인증 객체 생성
memberAccount = MemberAccount.create(profile.email(), MemberRole.USER);
commandMemberAccountService.save(memberAccount);
// B-2. 소셜 계정 객체 생성
SocialUserProfileInfo socialUserProfileInfo = SocialUserProfileInfo.create(
profile.providerId(),
profile.connectedAt(),
profile.emailAgreed(),
profile.profileNicknameAgreed(),
profile.profileImageUrl(),
profile.profileThumbnailImageUrl()
);
SocialAccount createSocialAccount = SocialAccount.create(memberAccount.getId(), socialUserProfileInfo);
commandSocialAccountService.save(createSocialAccount);
// B-3. 사용자 프로필 객체 생성
UserProfileInfo userProfileInfo = UserProfileInfo.create(profile.nickname());
UserProfile userProfile = UserProfile.create(memberAccount.getId(), userProfileInfo);
commandUserProfileService.save(userProfile);
// B-4. 이메일로 회원가입 축하 메시지 발송
messageService.sendWelcomeEmail(memberAccount.getEmail());
}
TokenClaimsRequest claimsRequest = commandTokenService.createTokenClaimsRequest(memberAccount.getId(), memberAccount.getEmail(), memberAccount.getRole());
return commandTokenService.issueTokenPair(claimsRequest);
}(1) OCP 위배 이유
: 메시지를 보내야하는 기능을 추가하고자 하면, 기존에 작성된 핵심 비즈니스 로직인 login() 함수를 수정해야 합니다. 이는 OCP를 위배합니다.
(2) 논리적 강결합 증가 이유
: 메시지를 보내야하는 기능이 추가되면, account 모듈에서 notification 모듈에 존재하는 MessageService를 의존하여 호출해야 합니다. 이는 account가 notification에 대한 의존성을 가지게 되어 논리적으로 강결합이 발생하게 됩니다.
EDA를 도입하면 OCP 위배 및 논리적 강결함 증가 문제를 해결할 수 있습니다.
STEP 2. 이벤트 기반 아키텍처(EDA) 도입하기
STEP 2-1. ApplicationEventPublisher, @EventListener를 사용한 이벤트 처리 방식
| 개념 | 설명 |
|---|---|
| ApplicationEventPublisher | 이벤트를 발행(Publish)하는 주체 |
| @EventListener | 발생한 이벤트를 구독(Subscribe)하여 소비(Consume)하는 수신처 |
Spring Boot에서는 ApplicationEventPublisher @EventListener를 이용해 EDA를 구현할 수 있습니다.
저희의 목표는 절차적 처리 방식 코드를 이벤트 기반 처리 방식으로 변경하는 것입니다.
비기능 요구사항 관점으로 접근하고 분석해보겠습니다.
[신규 가입자]: 기존에 가입한 이력이 없는 사용자를 의미합니다.
- MemberAccount 도메인 규칙을 적용해 데이터를 생성합니다.
- SocialAccount 도메인 규칙을 적용해 데이터를 생성합니다.
- UserProfile 도메인 규칙을 적용해 데이터를 생성합니다.
- MessageService를 통해 이메일로 회원가입 축하 메시지를 발송합니다.
[분석]
- SocialAccount는 MemberAccount의 id를 참조하고 있습니다.
- UserProfile는 MemberAccount의 id를 참조하고 있습니다.
- MessageService는 MemberAccount의 email을 요청으로 받아 이메일로 회원가입 축하 메시지를 발송합니다.
[결론]
- MemberAccount의 데이터가 적재될 때 SocialAccount, UserProfile 데이터가 적재되어야 하며, sendWelcomeEmail() 함수를 통해 이메일로 회원가입 축하 메시지를 발송해야 합니다.
- SocialAccount, UserProfile의 데이터 적재와 sendWelcomeEmail() 함수 호출은 이벤트로 간주되어야 합니다.
구현 방향성
1. 이벤트 발생 트리거 VO 객체 생성 => "이벤트(Event)"
: 이벤트 발생 트리거 VO 객체는 이벤트 발생 시 전달되는 데이터를 담고 있는 객체입니다.
// account.domain.event.SocialAccountLinkRequestedEvent.java
public record SocialAccountLinkRequestedEvent(
Long memberId,
OAuth2Provider provider,
String providerId,
Instant socialConnectedAt,
boolean socialEmailAgreed,
boolean socialNicknameAgreed
) {
}
// profile.domain.event.UserProfileCreatedEvent.java
public record UserProfileCreatedEvent(
Long memberId,
String nickname
) {
}
// notification.domain.event.WelcomeEmailSentEvent.java
public record WelcomeEmailSentEvent(
Long memberId,
String email
) {
}2. 이벤트 핸들러 구현 => "이벤트 구독자(Consumer)"
: 이벤트 핸들러는 이벤트를 구독(Subscribe)하여 소비(Consume)하는 수신처입니다.
// account.application.event.SocialAccountEventHandler.java
@EventListener
public void handleSocialAccountCreation(SocialAccountLinkRequestedEvent event) {
SocialAccount socialAccount = commandSocialAccountService.saveSocialAccount(
event.provider(),
event.memberId(),
event.providerId(),
event.socialConnectedAt(),
event.socialEmailAgreed(),
event.socialNicknameAgreed()
);
}
// profile.application.event.UserProfileEventHandler.java
@EventListener
public void handleUserProfileCreation(UserProfileCreatedEvent event) {
UserProfile userProfile = commandUserProfileService.saveUserProfile(
event.memberId(),
event.nickname()
);
}
// notification.application.event.WelcomeEmailEventHandler.java
@EventListener
public void handleWelcomeEmailSending(WelcomeEmailSentEvent event) {
messageService.sendWelcomeEmail(event.email());
}3. 이벤트 발행 트리거 함수 구현 => "이벤트 발행자(Publisher)"
: 이벤트 발행 트리거 함수는 이벤트를 발행(Publish)하는 함수입니다.
...
private final ApplicationEventPublisher eventPublisher;
@Transactional
public PairTokenResponse login(KakaoSocialLoginCommand command) {
SocialUserProfileResult profile = loadKakaoUserProfilePort.load(command.authorizationCode());
SocialAccount socialAccount = querySocialAccountService.findExistingSocialAccount(command.provider(), Long.valueOf(profile.providerId()))
.orElse(null);
MemberAccount memberAccount;
if (socialAccount != null) {
// [Case A] 기존 가입자: 안전하게 기존 객체의 필드 접근
memberAccount = queryMemberAccountService.findById(socialAccount.getMemberAccountId());
} else {
// [Case B] 신규 가입자: 새로운 객체 생성
memberAccount = MemberAccount.create(profile.email(), MemberRole.USER);
commandMemberAccountService.save(memberAccount);
// B-1. 이벤트 발생 트리거 함수 호출
eventPublisher.publishEvent(new SocialAccountLinkRequestedEvent(
memberAccount.getId(),
command.provider(),
profile.providerId(),
profile.connectedAt(),
profile.emailAgreed(),
profile.profileNicknameAgreed()
));
// B-2. 이벤트 발생 트리거 함수 호출
eventPublisher.publishEvent(new UserProfileCreatedEvent(
memberAccount.getId(),
profile.nickname()
));
// B-3. 이벤트 발생 트리거 함수 호출
eventPublisher.publishEvent(new WelcomeEmailSentEvent(
memberAccount.getId(),
memberAccount.getEmail()
));
}
TokenClaimsRequest claimsRequest = commandTokenService.createTokenClaimsRequest(memberAccount.getId(), memberAccount.getEmail(), memberAccount.getRole());
return commandTokenService.issueTokenPair(claimsRequest);
}EDA 방식으로 리팩토링하여 account 모듈이 다른 모듈에 대한 의존성(commandUserProfileService, commandSocialAccountService, messageService)을 낮추어 논리적 결합을 낮추게 됩니다.
또 다른 문제는 발생하지 않을까요? 발생할 확률이 있습니다.
🔥 OCP 위배는 여전히 남아있다.
만약, 기능이 추가되어 카카오톡 회원가입 축하메시지를 보내야한다면 login() 함수를 수정해야 합니다. 이는 여전히 핵심 비즈니스 로직을 수정해야하기 때문에 OCP를 위배하게 됩니다.
🔥모듈을 MSA로 분리하게 될 때 문제가 발생합니다.
이뿐만 아니라, 모듈을 MSA로 분리하게 되면 문제가 발생합니다.
예를 들어, notification 모듈을 다른 프로젝트로 이관하여 MSA 방식으로 구축한다고 가정해보겠습니다.
MSA방식으로 구축한다는 것은 다른 프로젝트로 notification 모듈이 옮겨가는 것을 의미하는데요.
이는 기존의 프로젝트에서 notification 모듈이 지워지게됩니다. (것을 의미합니다.)
이렇게 된다면 notification 모듈의 WelcomeEmailSentEvent가 없어지게 되어 기존의 프로젝트에서 사용할 수 없게 됩니다.
🔥 Transactional 어노테이션 문제
위에서 언급하지는 않았지만 loadKakaoUserProfilePort.load()는 "외부 API"를 호출하는 함수입니다.
여기서의 외부 API는 "소셜 프로바이더(카카오, 네이버, 구글 등)에서 사용자 정보를 제공하는 주체"를 의미합니다.
login()함수는 외부 API를 호출하는 함수를 포함하는데, 이 함수에 @Transactional 어노테이션이 붙어있습니다.
트랜잭션이 시작되면 스레드는 DB 커넥션 풀에서 커넥션 하나를 점유합니다. 이 커넥션은 트랜잭션이 Commit 또는 Rollback될 때까지 반환되지 않습니다.
만약 외부 서비스의 응답 시간이 길어지게 된다면 DB 커넥션은 아무 일도 하지 않고 트랜잭션을 유지하며 대기합니다. 동시 사용자가 많아진다면 DB 커넥션 풀이 고갈되고, 서비스는 응답 없음(Timeout) 상태가 됩니다.
STEP 2-1. shared-kernel Pattern을 사용한 이벤트 처리 방식: 공개 API 방식
shared-kernel은 각 모듈이 서로의 내부 도메인을 직접 참조하지 못하게 막고, 시스템 전체에서 공통으로 사용하는 이벤트 객체와 DTO, 인터페이스만을 모아둔 별도의 공유 모듈(Shared Kernel)을 만드는 방식입니다.
shared-kernel은 단순히 여러 곳에서 같이 쓰이는 객체를 모아두는 곳이 아닙니다.
shared-kernel == 공유 도메인 모듈이라고 이해하면 좋습니다.
즉, 시스템의 핵심 비즈니스 규칙과 관련된 도메인 객체(vo, dto, interface)를 공유하는 것을 말합니다.
shared-kernel의 엄격한 규칙은 아래와 같습니다.
- shared-kernel 모듈을 도입하면 모듈(account, profile 등)간의 직접 의존은 절대 금지됩니다.
- shared-kernel은 절대! 어떤 모듈도 의존하면 안됩니다.
- 1無_No Business Logic(비즈니스 로직 제로):
- shared-kernel 모듈에는 상태를 변경하거나 복잡한 계산을 수행하는 Service/UseCase 클래스가 들어가면 안 됩니다. 오직 데이터를 담고 전달하는 역할(Event, DTO, VO, Interface)만 수행해야 합니다.
- 2無_Low Volatility(낮은 변동성):
- 모든 비즈니스 모듈이 이 Shared Kernel을 의존하기 때문에, 이곳의 클래스가 변경되면 전체 시스템에 파급 효과(Ripple Effect)가 발생합니다. 따라서 시스템 전반에서 합의된, 변경될 확률이 극히 적은 견고한 핵심 도메인 개념만 두어야 합니다.
- 3無_Zero Framework Dependency(프레임워크 의존성 제로):
- Shared Kernel 내부의 클래스들은 가급적 순수 자바 객체(POJO)여야 합니다. Spring Web(HttpServletRequest), Spring Data JPA(@Entity, @Table), Spring Security 등의 어노테이션이나 클래스가 import 되어서는 안 됩니다. (이벤트 발행을 위한 규격 정도만 예외적으로 허용합니다.)
만약 한 프로젝트에 common 모듈과 shared-kernel 모듈이 모두 존재한다면,
common 모듈은 shared-kernel 모듈을 의존할 수 있습니다.
shared-kernel 적용하기
(1) shared-kernel 적용 패키지 구조 예시
src/
└── shared-kernel/
├── event
| └── MemberRegisteredEvent.java
├── dto/
├── vo/
└── interface/(2) shared-kernel 이벤트 객체 코드 예시
public record MemberAccountRegisteredEvent(
Long memberId,
String email,
String nickname,
String provider,
String providerId,
Instant socialConnectedAt,
boolean socialEmailAgreed,
boolean socialNicknameAgreed
) {
}(3) 기존의 이벤트 핸들러 수정 코드 예시
// account.application.event.SocialAccountEventHandler.java
@EventListener
public void handleSocialAccountCreation(MemberAccountRegisteredEvent event) {
SocialAccount socialAccount = commandSocialAccountService.saveSocialAccount(
event.provider(),
event.memberId(),
event.providerId(),
event.socialConnectedAt(),
event.socialEmailAgreed(),
event.socialNicknameAgreed()
);
}
// profile.application.event.UserProfileEventHandler.java
@EventListener
public void handleUserProfileCreation(MemberAccountRegisteredEvent event) {
UserProfile userProfile = commandUserProfileService.saveUserProfile(
event.memberId(),
event.nickname()
);
}
// notification.application.event.WelcomeEmailEventHandler.java
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleWelcomeEmailSending(MemberAccountRegisteredEvent event) {
messageService.sendWelcomeEmail(event.email());
}(4)
private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transactionTemplate;
public PairTokenResponse login(KakaoSocialLoginCommand command) {
SocialUserProfileResult profile = loadKakaoUserProfilePort.load(command.authorizationCode());
MemberAccount memberAccount = transactionTemplate.execute(status -> getOrCreateMemberAccount(profile));
return commandTokenService.issueTokenPair(new TokenClaimsRequest(memberAccount.getId(), memberAccount.getEmail(), memberAccount.getRole()));
}
// [파일 내부 트랜잭션 처리] TransactionTemplate 사용
private MemberAccount getOrCreateMemberAccount(SocialUserProfileResult profile) {
SocialAccount socialAccount = querySocialAccountService.findExistingSocialAccount(profile.provider(), Long.valueOf(profile.providerId()))
.orElse(null);
MemberAccount memberAccount;
if (socialAccount != null) {
// [Case A] 기존 가입자: 안전하게 기존 객체의 필드 접근
memberAccount = queryMemberAccountService.findById(socialAccount.getMemberAccountId());
} else {
// [Case B] 신규 가입자: 새로운 객체 생성
memberAccount = MemberAccount.create(profile.email(), MemberRole.USER);
commandMemberAccountService.save(memberAccount);
// B-1. 이벤트 발생 트리거 함수 호출
eventPublisher.publishEvent(new MemberAccountRegisteredEvent(
memberAccount.getId(),
profile.email(),
profile.nickname(),
OAuth2Provider.valueOf(profile.provider()),
Long.valueOf(profile.providerId()),
profile.connectedAt(),
profile.emailAgreed(),
profile.profileNicknameAgreed()
));
}
return memberAccount;
}shared-kernel과 EDA를 적용하면 기존의 코드와 동일한 기능을 수행하지만, shared-kernel을 적용하여 모듈간의 결합을 낮추고 유지보수성은 높아집니다.
😂 리팩토링 되어진 코드에서도 아쉬운 부분이 남아있습니다.
제목에도 작성해두었듯이 현재 저희가 구축하는 것은 DDD 아키텍처를 기반으로 하는 EDA입니다.
DDD의 핵심은 도메인이 주체가 되어 도메인 규칙을 캡슐화하고, 자신의 상태 변경을 스스로 통제하는 것입니다.
하지만 현재 코드를 보면 getOrCreateMemberAccount()라는 Application 계층의 서비스 로직이 이벤트를 직접 생성하고 발행(Publish)하고 있습니다. 이는 도메인의 비즈니스 행위(회원가입 완료)가 도메인 객체가 아닌 서비스 클래스에 강하게 종속되어 있음을 의미합니다.
💬 해결방법: Aggregate Root와 @DomainEvents의 활용
이 문제를 우아하게 해결하려면 도메인 객체(Aggregate Root) 스스로가 자신이 생성되거나 변경되었을 때의 도메인 규칙인 '사실(Event)'을 내부에 품고 있도록 설계해야 합니다.
Spring Data에서는 이를 위해 @DomainEvents (또는 AbstractAggregateRoot)라는 강력한 기능을 제공합니다. 이 기능을 활용하면, 우리가 직접 eventPublisher.publishEvent()를 호출할 필요 없이 commandMemberAccountService.save(memberAccount)가 실행되어 데이터베이스에 트랜잭션이 커밋(Commit)되는 순간, 도메인 객체가 품고 있던 이벤트들이 자동으로 발행되게 만들 수 있습니다.
이렇게 구현한다면 이벤트 발행이라는 행위조차 완벽하게 도메인 모델 내부로 격리하여 진정한 의미의 DDD를 달성할 수 있습니다.
STEP 2-3. DomainEvents 아노테이션을 사용해 EDA 구현하기
최종적으로 수정된 코드에서 DDD의 본질에 맞게 리팩토링하겠습니다.
(1) 패키지 구조 방향성 예시
src/
|── shared-kernel/
| └── event/
|
|──── modules/
| └── account/
| ├── adapter/
| │ └── out/
| │ └── persistence/
| │
| ├── domain/
| └── application/
|
└──── common/
├── domain/
| └── type/
| └── AggregateRoot.java
| └── DomainAggregateRoot.java
└── adapter/
└── out/
└── persistence/
└── jpa/
└── BaseInstanceTimeJpaEntity.java
└── JpaAggregateRoot.java(1) 도메인 객체의 이벤트 수집과 외부 방출을 위한 추상클래스
// common.domain.type.AggregateRoot.java
public abstract class AggregateRoot {}
// common.domain.type.DomainAggregateRoot.java
public abstract class DomainAggregateRoot extends AggregateRoot {
private final List<Object> domainEvents = new ArrayList<>();
// 이벤트 내부 수집
protected void registerEvent(Object event) {
if (event != null) {
this.domainEvents.add(event);
}
}
// 내부에 등록된 모든 도메인 이벤트를 반환하고, 이벤트 저장소를 완전히 초기화(Clear)
public Collection<Object> pollAllEvents() {
if (domainEvents.isEmpty()) {
return Collections.emptyList();
}
List<Object> currentEvents = new ArrayList<>(this.domainEvents);
this.domainEvents.clear();
return Collections.unmodifiableCollection(currentEvents);
}
}(2) JPA Entity 객체의 이벤트 수집
// common.adapter.out.persistence.jpa.BaseInstanceTimeJpaEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseInstantTimeJpaEntity {
@Comment("생성 일시")
@CreatedDate
@Column(updatable = false, nullable = false, columnDefinition = "DATETIME(6)")
private Instant createdAt;
@Comment("수정 일시")
@LastModifiedDate
@Column(nullable = false, columnDefinition = "DATETIME(6)")
private Instant updatedAt;
}
// common.adapter.out.persistence.jpa.JpaAggregateRoot.java
@MappedSuperclass
public abstract class JpaAggregateRoot extends BaseInstantTimeJpaEntity {
@Transient // DB 컬럼으로 매핑되지 않도록 반드시 선언
private transient final List<Object> domainEvents = new ArrayList<>();
// POJO 도메인 객체에서 발생한 이벤트를 영속성 엔티티로 전이(이벤트 수집)
public void recordPersistenceEvent(Object event) {
if (event != null) {
this.domainEvents.add(event);
}
}
// Repository.save() 호출 시 Spring Data가 이 메서드를 찾아 이벤트를 발행
// save() 동작 이후, 자동 수행되는 메서드(이벤트 발행)
@DomainEvents
protected Collection<Object> domainEvents() {
return Collections.unmodifiableList(domainEvents);
}
// 이벤트 발행 완료 후 리스트를 비워 중복 발행을 방지
// @DomainEvents 어노테이션이 붙은 메서드가 실행된 이후, 자동 수행되는 메서드
@AfterDomainEventPublication
protected void clearDomainEvents() {
this.domainEvents.clear();
}
}(3) 도메인과 JPA Entity 객체의 예시코드
// account.domain.MemberAccount.java
@Getter
public class MemberAccount extends DomainAggregateRoot {
private Long id;
private String email;
private String password;
private MemberRole role;
private Instant createdAt;
private Instant updatedAt;
@Builder
private MemberAccount(
Long id,
String email,
String password,
MemberRole role,
Instant createdAt,
Instant updatedAt
) {
ensureInvariants(email, role);
this.id = id;
this.email = email;
this.password = password;
this.role = role;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
// FACTORY METHOD
public static MemberAccount create(String email, MemberRole role) {
return MemberAccount.builder()
.id(TsidCreator.getTsid().toLong())
.email(email)
.role(role)
.build();
}
// 소셜 계정으로 가입 시, 내부에서 이벤트 발행(적재)
public static MemberAccount createWithSocialLink(
String email,
MemberRole role,
SocialUserProfileInfo socialUserProfileInfo
) {
MemberAccount newMember = create(email, role);
newMember.completeSocialRegistration(socialUserProfileInfo);
return newMember;
}
// EVENT REGISTER
// SocialUserProfileInfo의 필드는 수정했다고 가정....
private void completeSocialRegistration(SocialUserProfileInfo socialUserProfileInfo) {
ensureSocialLinkInvariants(socialUserProfileInfo);
registerEvent(new MemberAccountRegisteredEvent(
this.id,
this.email,
socialUserProfileInfo.nickname(),
OAuth2Provider.valueOf(socialUserProfileInfo.provider()),
Long.valueOf(socialUserProfileInfo.providerId()),
socialUserProfileInfo.connectedAt(),
socialUserProfileInfo.emailAgreed(),
socialUserProfileInfo.profileNicknameAgreed()
));
}
// VALIDATE
private void ensureInvariants(String email, MemberRole role) {
// Domain 기본 규칙 강제 코드..
}
private static void ensureSocialLinkInvariants(SocialUserProfileInfo socialUserProfileInfo) {
// 소셜 계정 생성 규칙 강제 코드..
}
}
// account.adapter.out.persistence.jpa.MemberAccountJpaEntity.java
@Entity
@Table(name="member_accounts")
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Getter
public class MemberAccountJpaEntity extends JpaAggregateRoot implements Persistable<Long> {
@Id
private Long id;
@Comment("회원 이메일(로그인 아이디)")
@Column(updatable = false, unique = true, nullable = false)
private String email;
@Comment("회원 패스워드")
@Column(name="password")
private String password;
@Comment("회원 권한")
@Enumerated(EnumType.STRING)
private MemberRole role;
@Builder
private MemberAccountJpaEntity(
Long id,
String email,
String password,
MemberRole role,
Instant createdAt,
Instant updatedAt
) {
this.id = id;
this.email = email;
this.password = password;
this.role = role;
}
// thinking: 리팩토링 가능할듯...?
// Spring Data JPA가 save() 시 INSERT를 할지 UPDATE를 할지 판단할 수 있도록 Persistable 인터페이스를 구현
// => 생성 시간이 없으면 신규 객체로 간주
@Override
public boolean isNew() {
return getCreatedAt() == null;
}
}(4) CommandMemberAccountService: 도메인 객체를 영속성 엔티티로 전이하고, 이벤트를 발행하는 서비스
// 원래는 Port를 통해 Adapter구현체를 가져오는 방향으로 작성되어있는데, 편의를 위해 구현체로 작성..
// account.application.command.CommandMemberAccountService.java
@Service
@Transactional
@RequiredArgsConstructor
public class CommandMemberAccountService {
private final MemberAccountJpaEntityMapper memberAccountJpaEntityMapper;
private final MemberAccountJpaCommandRepository memberAccountJpaCommandRepository;
@Override
public MemberAccount save(MemberAccount memberAccount) {
MemberAccountJpaEntity entity = memberAccountJpaEntityMapper.toJpaEntity(memberAccount);
// Point 1: [Event Bridge] 도메인 이벤트를 꺼내어 영속성 엔티티로 전이
Collection<Object> domainEvents = memberAccount.pollAllEvents();
if (domainEvents != null && !domainEvents.isEmpty()) {
domainEvents.forEach(entity::recordPersistenceEvent);
// 사실, 반드시 동작하는 비즈니스 규칙 플로우 이벤트라면... 예외처리를 해주는 것이 좋을 것 같다.
}
// Point 2: DB 반영 (Spring Data가 엔티티의 이벤트를 자동 발행)
MemberAccountJpaEntity savedEntity = memberAccountJpaCommandRepository.save(entity);
return memberAccountJpaEntityMapper.toDomain(savedEntity);
}
}
// account.adapter.out.persistence.jpa.MemberAccountJpaCommandRepository.java
public interface MemberAccountJpaCommandRepository extends Repository<MemberAccountJpaEntity, Long> {
}(5) login()함수
private final TransactionTemplate transactionTemplate;
public PairTokenResponse login(KakaoSocialLoginCommand command) {
SocialUserProfileResult profile = loadKakaoUserProfilePort.load(command.authorizationCode());
MemberAccount memberAccount = transactionTemplate.execute(status -> getOrCreateMemberAccount(profile));
return commandTokenService.issueTokenPair(new TokenClaimsRequest(memberAccount.getId(), memberAccount.getEmail(), memberAccount.getRole()));
}
// [파일 내부 트랜잭션 처리] TransactionTemplate 사용
private MemberAccount getOrCreateMemberAccount(SocialUserProfileResult profile) {
SocialAccount socialAccount = querySocialAccountService.findExistingSocialAccount(profile.provider(), Long.valueOf(profile.providerId()))
.orElse(null);
MemberAccount memberAccount;
if (socialAccount != null) {
// [Case A] 기존 가입자: 안전하게 기존 객체의 필드 접근
memberAccount = queryMemberAccountService.findById(socialAccount.getMemberAccountId());
} else {
// [Case B] 신규 가입자: 새로운 객체 생성
SocialUserProfileInfo socialUserProfileInfo = {....}
memberAccount = MemberAccount.createWithSocialLink(profile.email(), MemberRole.USER, socialUserProfileInfo);
commandMemberAccountService.save(memberAccount);
}
return memberAccount;
}이번 포스팅에서 설명하고 싶었던 DDD의 본질을 적용한 EDA를 적용한 코드가 완성되었습니다 😂😂
😂.... 하지만... 더 좋은 방향의 설계가 있다고 합니다!
- Transactional Outbox Pattern의 도입
- Zero Class Dependency 적용

위의 두 방식은 Gemini를 이용하다가 얻은 인사이트입니다.
두 방식 모두 아직 경험을 해보지 않았기 때문에 차후 경험을 해보고 포스팅해볼까합니다.
마치며
도메인 간의 '선'을 긋는 즐거움, EDA 리팩토링을 통해 배운 것들
지금까지 DDD 아키텍처 위에서 절차적인 로그인 로직을 이벤트 기반 아키텍처(EDA)로 리팩토링하는 과정을 살펴보았습니다. 단순히 기술적인 구현 방식을 바꾼 것을 넘어, 이 과정을 반복하며 제가 느꼈던 핵심적인 가치들은 다음과 같습니다.
1. "관심사의 분리"가 주는 심리적 안정감
초기 리팩토링 전의 login() 메서드는 MemberAccount, SocialAccount, UserProfile 등 너무나 많은 도메인의 책임을 한곳에 짊어지고 있었습니다.
하지만 이벤트를 발행하고 구독하는 방식으로 전환하면서, 각 서비스는 오직 자신이 해야 할 일(자신의 도메인 데이터 관리)에만 집중할 수 있게 되었습니다.
이러한 결합도 해소는 코드의 가독성을 높일 뿐만 아니라, 이후 특정 도메인의 로직이 변경되어도 다른 도메인에 미칠 영향을 최소화해 주는 강력한 무기가 되었습니다.
2. '이벤트'는 도메인의 언어이자 소통 창구
이벤트를 정의하는 과정에서 "회원 계정이 등록되었다.(MemberAccountRegisteredEvent)"와 같은 도메인 행위의 결과를 명확히 정의하게 되었습니다.
이는 단순히 코드의 흐름을 제어하는 것을 넘어, 도메인 모델 간의 상호작용을 비즈니스 언어로 명시화하는 작업이었습니다.
ID 참조 방식을 택하며 객체 간의 물리적 연결은 끊었지만, 이벤트를 통해 논리적 흐름을 이어줌으로써 시스템은 더욱 유연해졌습니다.
3. 완성은 끝이 아닌 새로운 시작
- Transactional Outbox Pattern: 이벤트 발행의 원자성을 보장하기 위한 고민
- Zero Class Dependency: 도메인 간의 클래스 의존성을 완전히 제거하여 독립성을 극대화하는 설계
이러한 주제들은 Gemini를 통한 탐구와 리팩토링 과정에서 얻은 소중한 인사이트들입니다.
기술은 계속해서 변하고 더 나은 방식은 언제나 존재하기 마련입니다.
중요한 것은 "현재 우리 도메인의 복잡도를 해결하기 위해 왜 이 기술이 필요한가?"를 끊임없이 질문하며 최적의 선을 찾아가는 과정 그 자체라고 생각합니다.
누군가 보았을 땐, "조금은 과도하게 학습하는 것 아닌가?", "오버엔지니어링 아닌가?"라고 생각할 수 있습니다.
제가 추구하는 것은 기술의 스펙트럼과 이해도를 높여 적재적소에 맞는 기술을 선택하고 적용하는 것입니다.
앞으로도 계속해서 학습하고, 적용하고, 공유하며 성장해나가겠습니다.