9osari.log
← 목록으로

[리팩토링] 도메인 책임 위치를 잘못 잡았을 때 보이는 신호

사람에 붙인 자격증명을, 연동 채널로 옮긴 이야기

외부 시스템 연동을 위한 OAuth 자격증명(clientId / clientSecret / clientEmail)을 처음엔 Operator라는 “사람” 엔티티에 박았다. 일주일 뒤, 동일한 자격증명을 ExternalChannel이라는 “연동 채널” 엔티티로 옮겼다. 이 글은 그 사이에 무엇이 잘못이었는지, 어떤 신호로 잘못됨을 알아챘는지에 대한 기록이다.

TL;DR

1. 도메인 배경

우리 시스템은 동일한 종류의 보고서를 두 개의 외부 시스템(A, B)에 전송한다.

도메인 모델은 대략 이런 구조였다.

Organization (발신 주체)
 ├── List<Operator>         -- 이 기관에 등록된 담당자들 (이름, 이메일, 전화 …)
 └── List<ExternalChannel>  -- 이 기관이 각 외부 시스템과 맺은 연동 채널
                               (targetSystem, senderId, apiUser, apiKey)

ExternalChannel에는 이미 외부 시스템 A의 자격증명(apiUser / apiKey)이 들어 있었다. 즉 A 쪽은 처음부터 “채널이 자격증명을 보유하는 모델”이었다.

2. 첫 설계 — Operator에 OAuth 키를 박다

외부 시스템 B 연동을 새로 붙이면서, OAuth 자격증명을 어디에 둘지 정해야 했다. 내부 회의를 통해 내린 판단은 다음과 같았다.

“B는 사람마다 자격증명이 다를 수 있다. 한 기관에 여러 담당자가 있고, 어떤 담당자가 자기 키로 보낼 수도 있으니, 담당자별로 들고 있게 하자.”

그래서 Operator에 컬럼 두 개를 추가했다.

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. “키를 누가 발급받았느냐”(소유) ↔ “키가 누구의 속성이냐”(소속) — 사람이 발급받았어도, 그 키는 시스템 간 연동의 속성이지 사람의 속성이 아니다.
  2. “여러 명이 있을 수 있다”(다중성) ↔ “여러 키가 동시에 유효하다”(다중성) — 실제 운영은 기관 단위로 하나의 키 세트만 활성이다. 그건 다중성이 아니라 1:1이다.

3. 호출부에서 “키 가진 사람 찾기”가 반복된다

Operator에 키를 박으니, 실제로 키를 써야 하는 곳에서는 “이 기관의 담당자들 중에 키가 채워진 사람”을 매번 골라내야 했다. 두 군데에서 똑같은 패턴이 나타났다.

전송 시점:

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."));

검증 시점도 거의 동일했다:

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 / apiKeyExternalChannel에 있는데, B의 clientId / SecretOperator에 있었다. 두 자격증명은 도메인 의미상 완전히 같은 것이다 — “이 기관이 이 외부 시스템에 접속하기 위한 키”. 위치만 다를 이유가 없다.

이 비대칭이 두 번째 신호였다.

도메인 모델 안에서 개념적으로 동일한 것은 같은 위치에 있어야 한다. 그렇지 않다면 둘 중 하나는 잘못 자리잡고 있다.

5. 옮기다

옮긴 코드는 단순했다.

ExternalChannel에 컬럼 셋 추가:

@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에서 해당 필드 제거:

// ─── 제거 ───
// @Column(name = "client_id")     private String clientId;
// @Column(name = "client_secret") private String clientSecret;

호출부가 검색에서 직접 접근으로 바뀜:

// 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. 마이그레이션 시 주의점

7. 도메인 책임 위치를 잘못 잡는 신호들

이번 일을 겪고 다음에는 더 일찍 잡고 싶어서 정리한다.

① 호출부에 “X를 가진 Y 찾기” 검색이 반복된다면, X는 Y에 속해 있지 않다.

스트림 .filter().filter().sorted().findFirst()로 도메인 객체를 매번 골라내고 있다면, 그 필드가 그 객체에 속한다는 모델이 의심된다. 진짜로 속해 있다면 검색 없이 직접 접근할 수 있어야 한다.

② 개념적으로 같은 것은 같은 위치에 있어야 한다.

A의 apiUser / apiKey와 B의 clientId / Secret은 도메인 의미상 동일한 추상 — “기관이 특정 외부 시스템과 통신하기 위한 자격증명”이다. 한쪽은 채널, 한쪽은 사람에 있다면 그 자체가 모델 비대칭이고, 비대칭은 보통 한쪽이 틀린 거다.

③ “키를 누가 발급받았는가”와 “키가 누구의 속성인가”를 혼동하지 않는다.

사람이 신청해서 발급받은 키라도, 그 키의 도메인적 소유는 시스템 채널이다. 인간 행위자(사람)와 시스템 행위자(연동 채널)의 속성을 섞으면 모델이 무너진다.

④ 일찍 짓고 늦지 않게 옮긴다.

이 이전을 망설일 이유는 단 하나, 마이그레이션 비용이었다. 그런데 며칠만 더 늦었다면 ACK 폴링, verify, submit 세 군데에 자격증명 검색 로직이 깊이 박힌 채로 운영 데이터까지 쌓였을 거다. 모델이 틀린 걸 알아챈 순간이 옮기기 가장 싼 순간이다.

···
← PREV [리팩토링] 복잡한 승인 로직, SRP와 DRY 원칙으로 통합하기 NEXT → [보안] 취약점 보고서와 인증/인가