[리팩토링] 도메인 책임 위치를 잘못 잡았을 때 보이는 신호
사람에 붙인 자격증명을, 연동 채널로 옮긴 이야기
외부 시스템 연동을 위한 OAuth 자격증명(clientId / clientSecret / clientEmail)을 처음엔 Operator라는 “사람” 엔티티에 박았다. 일주일 뒤, 동일한 자격증명을 ExternalChannel이라는 “연동 채널” 엔티티로 옮겼다. 이 글은 그 사이에 무엇이 잘못이었는지, 어떤 신호로 잘못됨을 알아챘는지에 대한 기록이다.
TL;DR
- 호출부마다 “X 가진 Y 찾기” 로직이 반복된다면, X는 Y의 책임이 아닐 가능성이 높다.
- OAuth 자격증명(
clientId/Secret)을 “담당자”(Operator)에 박았더니, 호출부마다stream().filter().filter().sorted().findFirst()같은 “키 가진 사람 찾기” 코드가 반복됐다. - 자격증명의 실제 소속은 사람이 아니라 우리 시스템과 외부 시스템 사이의 연동 채널(
ExternalChannel)이었다. 이미 같은 묶음의 다른 외부 시스템 자격증명은 거기에 있었다. 한 쪽만 위치가 달랐다. - 위치를 옮기자 호출부의 검색 로직이 통째로 사라지고, 단순 null 체크만 남았다. 데이터를 어디에 두느냐는 단순한 매핑 문제가 아니라 그 데이터가 누구의 속성인가에 대한 도메인 판단이다.
1. 도메인 배경
우리 시스템은 동일한 종류의 보고서를 두 개의 외부 시스템(A, B)에 전송한다.
- 외부 시스템 A: 단순 API 키 인증.
apiUser/apiKey로 호출. - 외부 시스템 B: OAuth2 기반.
clientId+clientSecret으로 토큰을 받고,clientEmail로 등록된 채널을 통해 전송.
도메인 모델은 대략 이런 구조였다.
1
2
3
4
Organization (발신 주체)
├── List<Operator> -- 이 기관에 등록된 담당자들 (이름, 이메일, 전화 …)
└── List<ExternalChannel> -- 이 기관이 각 외부 시스템과 맺은 연동 채널
(targetSystem, senderId, apiUser, apiKey)
ExternalChannel에는 이미 외부 시스템 A의 자격증명(apiUser / apiKey)이 들어 있었다. 즉 A 쪽은 처음부터 “채널이 자격증명을 보유하는 모델”이었다.
2. 첫 설계 — Operator에 OAuth 키를 박다
외부 시스템 B 연동을 새로 붙이면서, OAuth 자격증명을 어디에 둘지 정해야 했다. 내부 회의를 통해 내린 판단은 다음과 같았다.
“B는 사람마다 자격증명이 다를 수 있다. 한 기관에 여러 담당자가 있고, 어떤 담당자가 자기 키로 보낼 수도 있으니, 담당자별로 들고 있게 하자.”
그래서 Operator에 컬럼 두 개를 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Operator extends BaseEntity {
private String name;
private String email;
private String telephone;
// ─── 추가 ───
@Column(name = "client_id")
private String clientId;
@Column(name = "client_secret")
private String clientSecret;
// ────────────
protected boolean selected;
...
}
며칠 뒤 verify 요구사항이 추가되며 clientEmail도 같은 자리에 들어갔다. B 시스템은 처음 도입하는 거라 PDM, 나, 팀장님 셋 다 잘 몰랐다.
지금 다시 보면 두 가지를 혼동한 결정이었다.
- “키를 누가 발급받았느냐”(소유) ↔ “키가 누구의 속성이냐”(소속) — 사람이 발급받았어도, 그 키는 시스템 간 연동의 속성이지 사람의 속성이 아니다.
- “여러 명이 있을 수 있다”(다중성) ↔ “여러 키가 동시에 유효하다”(다중성) — 실제 운영은 기관 단위로 하나의 키 세트만 활성이다. 그건 다중성이 아니라 1:1이다.
3. 호출부에서 “키 가진 사람 찾기”가 반복된다
Operator에 키를 박으니, 실제로 키를 써야 하는 곳에서는 “이 기관의 담당자들 중에 키가 채워진 사람”을 매번 골라내야 했다. 두 군데에서 똑같은 패턴이 나타났다.
전송 시점:
1
2
3
4
5
6
7
8
Operator targetOperator = organization.getOperators().stream()
.filter(o -> TargetSystemType.B.equals(o.getTargetSystem()))
.filter(o -> o.getClientId() != null && !o.getClientId().isEmpty())
.filter(o -> o.getClientSecret() != null && !o.getClientSecret().isEmpty())
.sorted((a, b) -> Boolean.compare(b.isSelected(), a.isSelected()))
.findFirst()
.orElseThrow(() -> new InvalidValueException(ErrorCode.INVALID_VALUE,
"Operator with client-id/secret not found."));
검증 시점도 거의 동일했다:
1
2
3
4
5
6
7
8
9
10
11
Operator credentialOperator = organization.getOperators().stream()
.filter(o -> TargetSystemType.B.equals(o.getTargetSystem()))
.filter(o -> /* clientId, clientSecret, email 모두 채워졌는지 */)
.sorted(...)
.findFirst()
.orElse(null);
if (credentialOperator == null) {
log.debug("verify skipped: no operator with credentials filled");
return;
}
이 코드가 말하는 건 분명했다.
“여러 명 중에 키 가진 사람을 골라야 한다”는 검색 로직이 호출부마다 등장한다면, 그건 키가 사람에 속해 있지 않다는 신호다.
원래 키가 사람의 속성이라면 “이 사람의 키”를 직접 꺼내면 되지, “키가 있는 사람”을 찾을 일이 없다. 검색이 필요하다는 건 키가 사실은 더 위(혹은 옆) 어딘가에 속해야 한다는 뜻이었다.
4. 다른 외부 시스템과의 비대칭
ACK 폴링을 위해 자격증명을 배치 엔티티에 함께 영속시키는 작업을 하다가, 커밋 메시지에 무심코 이렇게 썼다.
“외부 시스템 A의
apiUser/apiKey패턴과 동일하게 정리.”
쓰고 나서 멈췄다. “같은 패턴”이라고 표현했지만, 위치는 같지 않다. A의 apiUser / apiKey는 ExternalChannel에 있는데, B의 clientId / Secret은 Operator에 있었다. 두 자격증명은 도메인 의미상 완전히 같은 것이다 — “이 기관이 이 외부 시스템에 접속하기 위한 키”. 위치만 다를 이유가 없다.
이 비대칭이 두 번째 신호였다.
도메인 모델 안에서 개념적으로 동일한 것은 같은 위치에 있어야 한다. 그렇지 않다면 둘 중 하나는 잘못 자리잡고 있다.
5. 옮기다
옮긴 코드는 단순했다.
ExternalChannel에 컬럼 셋 추가:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Column(name = "client_id") private String clientId;
@Column(name = "client_secret") private String clientSecret;
@Column(name = "client_email") private String clientEmail;
public void update(String targetSystem, String senderId,
String apiUser, String apiKey,
String clientId, String clientSecret, String clientEmail) {
this.targetSystem = targetSystem;
this.senderId = senderId;
this.apiUser = apiUser;
this.apiKey = apiKey;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.clientEmail = clientEmail;
}
Operator에서 해당 필드 제거:
1
2
3
// ─── 제거 ───
// @Column(name = "client_id") private String clientId;
// @Column(name = "client_secret") private String clientSecret;
호출부가 검색에서 직접 접근으로 바뀜:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before — "키 가진 사람 찾기"
Operator targetOperator = organization.getOperators().stream()
.filter(o -> TargetSystemType.B.equals(o.getTargetSystem()))
.filter(o -> o.getClientId() != null && !o.getClientId().isEmpty())
.filter(o -> o.getClientSecret() != null && !o.getClientSecret().isEmpty())
.sorted((a, b) -> Boolean.compare(b.isSelected(), a.isSelected()))
.findFirst()
.orElseThrow(...);
// After — "이 채널에 키 있냐"
if (channel.getClientId() == null || channel.getClientId().isEmpty()
|| channel.getClientSecret() == null || channel.getClientSecret().isEmpty()) {
throw new InvalidValueException(ErrorCode.INVALID_VALUE,
"Channel with client-id/secret not found.");
}
검색 로직이 통째로 사라졌다. 핵심은 줄어든 라인 수가 아니라 “어떤 operator를 골라야 하지?”라는 질문 자체가 코드에서 사라졌다는 점이다. 그 질문은 애초에 잘못된 모델 때문에 생긴 것이었다.
동작 변경 없는 순수 이전이었다.
6. 마이그레이션 시 주의점
- DB 컬럼 이동:
operator.client_*→external_channel.client_*. 운영 데이터는 SQL로 1회 카피 후 구 컬럼 drop. 같은 트랜잭션에 신컬럼 채움 + 신코드 배포가 묶여야 한다. - DTO에서도 같이 이동:
OperatorDto의clientId/Secret필드를 제거하고ExternalChannelDto로 옮김. 응답 스키마가 바뀌므로 프론트와 사전 합의 필요. - update 시그니처 확장:
ExternalChannel을 업데이트할 때 새 필드를 같이 넘기도록 시그니처 확장. clientEmail ≠ operator.email: 사람의 이메일과 외부 시스템에 등록된 채널 이메일은 일치할 수도, 다를 수도 있다. 이전을 하면서 이걸 비로소 분리해 명시했다.
7. 도메인 책임 위치를 잘못 잡는 신호들
이번 일을 겪고 다음에는 더 일찍 잡고 싶어서 정리한다.
① 호출부에 “X를 가진 Y 찾기” 검색이 반복된다면, X는 Y에 속해 있지 않다.
스트림 .filter().filter().sorted().findFirst()로 도메인 객체를 매번 골라내고 있다면, 그 필드가 그 객체에 속한다는 모델이 의심된다. 진짜로 속해 있다면 검색 없이 직접 접근할 수 있어야 한다.
② 개념적으로 같은 것은 같은 위치에 있어야 한다.
A의 apiUser / apiKey와 B의 clientId / Secret은 도메인 의미상 동일한 추상 — “기관이 특정 외부 시스템과 통신하기 위한 자격증명”이다. 한쪽은 채널, 한쪽은 사람에 있다면 그 자체가 모델 비대칭이고, 비대칭은 보통 한쪽이 틀린 거다.
③ “키를 누가 발급받았는가”와 “키가 누구의 속성인가”를 혼동하지 않는다.
사람이 신청해서 발급받은 키라도, 그 키의 도메인적 소유는 시스템 채널이다. 인간 행위자(사람)와 시스템 행위자(연동 채널)의 속성을 섞으면 모델이 무너진다.
④ 일찍 짓고 늦지 않게 옮긴다.
이 이전을 망설일 이유는 단 하나, 마이그레이션 비용이었다. 그런데 며칠만 더 늦었다면 ACK 폴링, verify, submit 세 군데에 자격증명 검색 로직이 깊이 박힌 채로 운영 데이터까지 쌓였을 거다. 모델이 틀린 걸 알아챈 순간이 옮기기 가장 싼 순간이다.