
프로그래밍을 처음 배울 때 의존성은 보통 “한 코드가 다른 코드를 가져와 사용하는 관계”라고 설명한다.
import {
couponRepository,
} from "./coupon-repository";
async function getCoupon(
couponId: string
) {
return couponRepository.findById(
couponId
);
}
getCoupon 함수는 쿠폰을 조회하기 위해 couponRepository를 사용한다. 따라서 이 함수는 저장소에 의존한다.
의존성의 기본적인 관계를 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 의존성은 import 문이나 함수 호출만으로 설명하기 어렵다.
문법적으로 의존성은 다른 함수, 클래스, 모듈을 사용하는 관계다.
실제 서비스에서는 한 구성요소의 변경, 실패, 속도, 생명주기가 다른 구성요소에 어떤 영향을 주는지를 결정하는 구조다.
실제 서비스에서 의존성을 설계한다는 것은 다른 코드를 가져다 쓰는 방법을 정하는 것이 아니라, 구성요소들이 서로 어떤 약속을 맺고 그 대상의 변경과 실패가 어디까지 퍼지도록 허용할지를 결정하는 일이다.
쿠폰을 발행한 뒤 저장소에 저장한다고 생각해 보자.
async function issueCoupon(
input: IssueCouponInput
) {
const coupon =
createCoupon(input);
return couponRepository.save(
coupon
);
}
issueCoupon은 단순히 save라는 함수를 호출하는 것이 아니다.
저장소가 여러 약속을 지킬 것이라고 기대한다.
Coupon을 입력으로 받는다.타입 선언에는 이 약속 중 일부만 나타난다.
save(
coupon: Coupon
): Promise<Coupon>
실제 의존성의 계약은 타입보다 넓다.
| 계약 요소 | 확인할 질문 |
|---|---|
| 입력 | 어떤 데이터를 전달해야 하는가? |
| 출력 | 성공하면 무엇을 반환하는가? |
| 실패 | 오류를 던지는가, 실패 결과를 반환하는가? |
| 속도 | 어느 시간 안에 완료되어야 하는가? |
| 부작용 | 데이터 저장이나 메시지 전송이 발생하는가? |
| 재실행 | 같은 작업을 다시 실행해도 안전한가? |
| 생명주기 | 인스턴스를 얼마나 오래 공유하는가? |
다른 구성요소를 호출하는 순간 호출자는 그 대상의 함수 이름만이 아니라 행동과 운영 특성에도 의존한다.
다음 함수는 coupon만 매개변수로 받는다.
function isCouponExpired(
coupon: Coupon
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= new Date()
);
}
별도의 import는 없지만 결과는 현재 시간에 의존한다.
같은 쿠폰을 전달해도 호출한 시점에 따라 결과가 달라진다.
환경변수도 숨겨진 의존성이 될 수 있다.
function getMaximumCouponUses() {
return Number(
process.env.MAX_COUPON_USES
);
}
현재 로그인한 사용자나 전역 상태에 의존할 수도 있다.
function canRedeemCoupon(
coupon: Coupon
) {
return (
coupon.recipientId ===
currentUser.id
);
}
이 함수들은 선언만 읽어서는 결과에 영향을 주는 모든 정보를 알기 어렵다.
숨겨진 값을 명시적인 입력으로 드러낼 수 있다.
function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
);
}
function canRedeemCoupon(
coupon: Coupon,
userId: string
): boolean {
return (
coupon.recipientId === userId
);
}
이제 호출부에서 판단에 필요한 정보를 확인할 수 있다.
const canRedeem =
canRedeemCoupon(
coupon,
request.currentUser.id
);
모든 값을 개별 매개변수로 전달해야 한다는 뜻은 아니다.
다만 결과나 부작용에 영향을 주는 값이 어디에서 오는지는 추적할 수 있어야 한다.
의존성은
import문으로만 찾는 것이 아니다. 결과에 영향을 주지만 호출부에 드러나지 않는 값도 의존성이다.
쿠폰 발행 함수가 Prisma를 직접 사용한다고 생각해 보자.
async function issueCoupon(
input: IssueCouponInput
) {
const coupon =
createCoupon(input);
return prisma.coupon.create({
data: {
id: coupon.id,
title: coupon.title,
recipientId:
coupon.recipientId,
remainingUses:
coupon.remainingUses,
},
});
}
이 함수는 쿠폰을 발행하는 비즈니스 행동과 Prisma의 저장 형식을 동시에 알고 있다.
데이터베이스 열 이름이나 ORM이 바뀌면 쿠폰 발행 코드도 수정해야 한다.
쿠폰 발행 기능이 실제로 필요로 하는 것은 Prisma 자체가 아니다.
쿠폰을 저장할 수 있는 역할이다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
쿠폰 발행 함수가 이 역할에 의존하도록 만들 수 있다.
async function issueCoupon(
input: IssueCouponInput,
repository: CouponRepository
): Promise<Coupon> {
const coupon =
createCoupon(input);
return repository.save(coupon);
}
Prisma 사용법은 저장소 구현 안에 둔다.
class PrismaCouponRepository
implements CouponRepository {
async save(
coupon: Coupon
): Promise<Coupon> {
const row =
await prisma.coupon.create({
data: toCouponRow(coupon),
});
return toCoupon(row);
}
}
이제 쿠폰 발행 규칙은 Prisma의 함수 이름이나 데이터베이스 열 구조를 알 필요가 없다.
중요한 변화는 인터페이스가 하나 추가되었다는 사실이 아니다.
비즈니스 코드가 특정 기술의 사용법이 아니라 자신에게 필요한 역할을 표현하게 되었다는 점이다.
쿠폰 규칙이 Prisma에 직접 의존하면 구조는 다음과 같다.
flowchart LR
A[쿠폰 규칙]
--> B[Prisma]
--> C[PostgreSQL]
Prisma의 타입이나 API가 바뀌면 쿠폰 규칙도 영향을 받을 수 있다.
필요한 역할을 중심으로 의존 방향을 바꿀 수 있다.
flowchart TD
A[쿠폰 사용 사례]
--> B[CouponRepository 계약]
C[Prisma 저장소 구현]
--> B
C --> D[PostgreSQL]
쿠폰 사용 사례는 CouponRepository가 어떤 기술로 구현되었는지 알지 않는다.
Prisma 저장소는 사용 사례가 요구한 계약을 구현한다.
이 구조에서는 변경의 영향을 구분하기 쉬워진다.
좋은 의존성 방향은 모든 변경을 없애지 않는다.
변경이 관련된 책임의 경계 안에서 퍼지도록 제한한다.
의존성 주입은 흔히 생성자에 다른 객체를 전달하는 코드로 배운다.
class CouponService {
constructor(
private readonly repository:
CouponRepository
) {}
}
NestJS에서는 프레임워크가 등록된 구현을 찾아 주입한다.
@Injectable()
class CouponService {
constructor(
@Inject("CouponRepository")
private readonly repository:
CouponRepository
) {}
}
문법만 보면 객체를 자동으로 넣어주는 편의 기능처럼 보인다.
하지만 핵심은 객체를 사용하는 책임과 객체를 생성하고 선택하는 책임을 분리하는 데 있다.
다음 클래스는 자신의 의존성을 직접 만든다.
class CouponService {
private readonly repository =
new PrismaCouponRepository();
}
이 클래스는 Prisma 저장소에 고정된다.
저장 기술을 바꾸거나 테스트용 저장소를 사용하려면 클래스 내부를 수정해야 한다.
외부에서 구현을 전달하면 구성하는 영역이 적절한 구현을 선택할 수 있다.
const repository =
new PrismaCouponRepository();
const service =
new CouponService(repository);
테스트에서는 메모리 저장소를 전달할 수 있다.
const repository =
new InMemoryCouponRepository();
const service =
new CouponService(repository);
의존성 주입의 실무적 의미는 다음과 같다.
의존성 주입은 객체를 전달하는 기법이 아니라, 누가 구현을 선택하고 생성하며 연결할 것인지 책임을 분리하는 방법이다.
저장소 앞에 인터페이스를 추가했다고 생각해 보자.
interface CouponRepository {
save(
input:
Prisma.CouponCreateInput
): Promise<PrismaCoupon>;
}
인터페이스는 있지만 Prisma의 타입을 그대로 노출한다.
사용 사례는 여전히 Prisma.CouponCreateInput과 PrismaCoupon을 알아야 한다.
인터페이스가 특정 기술에 대한 의존성을 감싸기만 했을 뿐 제거하지는 못했다.
서비스의 언어로 계약을 표현하는 편이 낫다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
findById(
couponId: string
): Promise<Coupon | null>;
}
Prisma 타입과 서비스 타입 사이의 변환은 구현 내부에서 처리한다.
class PrismaCouponRepository
implements CouponRepository {
async findById(
couponId: string
): Promise<Coupon | null> {
const row =
await prisma.coupon.findUnique({
where: {
id: couponId,
},
});
return row
? toCoupon(row)
: null;
}
}
인터페이스는 결합을 줄이기 위한 장식이 아니다.
사용하는 쪽이 기대하는 역할과 데이터를 표현해야 한다.
다음 함수는 쿠폰의 표시 문구를 만든다.
function formatCouponLabel(
coupon: Coupon
): string {
return (
`${coupon.title} · ` +
`${coupon.remainingUses}회`
);
}
이 함수는 외부 시스템과 통신하지 않고 같은 입력에 같은 결과를 반환한다.
반드시 인터페이스와 구현 클래스로 나눌 필요는 없다.
interface CouponLabelFormatter {
format(
coupon: Coupon
): string;
}
class DefaultCouponLabelFormatter
implements CouponLabelFormatter {
format(
coupon: Coupon
): string {
// ...
}
}
교체할 이유가 없고 테스트하기 쉬운 내부 함수라면 직접 호출하는 편이 더 명확할 수 있다.
별도의 계약이 특히 유용한 의존성은 다음과 같다.
의존성 설계의 목적은 인터페이스 수를 늘리는 것이 아니다.
변경 가능성과 실패 가능성이 큰 경계를 분명하게 만드는 것이다.
쿠폰을 저장한 뒤 이메일을 전송한다고 생각해 보자.
async function issueCoupon(
input: IssueCouponInput
) {
const coupon =
await couponRepository.save(
createCoupon(input)
);
await emailService.sendCouponIssued(
coupon
);
return coupon;
}
이메일 전송이 실패하면 issueCoupon 함수도 실패한다.
그러나 쿠폰은 이미 데이터베이스에 저장되었을 수 있다.
사용자에게 오류를 보여주면 사용자가 요청을 다시 실행해 쿠폰이 중복 생성될 수도 있다.
여기서 필요한 질문은 단순히 try-catch를 어디에 작성할 것인지가 아니다.
이메일 전송은 쿠폰 발행의 필수 결과인가, 아니면 발행 이후 수행할 후속 작업인가?
이메일이 반드시 성공해야 쿠폰 발행이 유효하다면 두 작업의 실패를 함께 관리해야 한다.
반대로 쿠폰 저장이 핵심이고 이메일은 다시 시도할 수 있다면 의존 관계를 느슨하게 만들 수 있다.
const coupon =
await couponRepository.save(
createCoupon(input)
);
await eventPublisher.publish({
type: "CouponIssued",
couponId: coupon.id,
});
return coupon;
이벤트 처리기는 별도로 이메일을 보낸다.
async function handleCouponIssued(
event: CouponIssuedEvent
) {
const coupon =
await couponRepository.findById(
event.couponId
);
if (!coupon) {
return;
}
await emailService.sendCouponIssued(
coupon
);
}
이 구조에서는 쿠폰 발행과 이메일 전송을 시간적으로 분리할 수 있다.
하지만 이벤트를 사용한다고 모든 문제가 해결되는 것은 아니다.
동기 호출과 비동기 처리 중 어떤 방식이 적절한지는 비즈니스 요구사항에 따라 달라진다.
의존성을 연결하는 방식은 실패가 어디까지 전파되고 사용자가 무엇을 기다려야 하는지를 결정한다.
쿠폰을 발행하기 전에 외부 회원 서비스에서 수신자를 확인한다고 생각해 보자.
const recipient =
await userApi.findById(
input.recipientId
);
외부 서비스가 정상일 때는 단순하게 동작한다.
하지만 회원 서비스가 느려지면 쿠폰 발행도 느려진다.
회원 서비스가 중단되면 쿠폰 서비스의 코드가 정상이어도 쿠폰을 발행하지 못할 수 있다.
외부 의존성은 기능만 제공하지 않는다.
다음과 같은 운영 특성도 함께 가져온다.
따라서 외부 의존성을 추가할 때는 다음 질문에 답해야 한다.
const recipient =
await userApi.findById(
input.recipientId,
{
timeoutMs: 2000,
}
);
외부 의존성을 추가하는 것은 새로운 기능을 얻는 일이면서 새로운 실패 경로를 서비스에 연결하는 일이기도 하다.
다음 서비스는 많은 의존성을 받는다.
class CouponService {
constructor(
private readonly couponRepository:
CouponRepository,
private readonly userRepository:
UserRepository,
private readonly emailService:
EmailService,
private readonly eventPublisher:
EventPublisher,
private readonly auditLogger:
AuditLogger,
private readonly policyRepository:
PolicyRepository,
private readonly clock:
Clock,
private readonly idGenerator:
IdGenerator
) {}
}
의존성이 많다고 무조건 잘못된 클래스는 아니다.
하나의 실제 작업이 여러 구성요소와 협력해야 할 수도 있다.
하지만 의존성 목록이 길다면 다음 가능성을 확인해야 한다.
사용 사례별로 클래스를 나눌 수 있다.
class IssueCoupon {
constructor(
private readonly repository:
CouponRepository,
private readonly eventPublisher:
EventPublisher,
private readonly clock:
Clock,
private readonly idGenerator:
IdGenerator
) {}
}
class RedeemCoupon {
constructor(
private readonly repository:
CouponRepository,
private readonly policyRepository:
PolicyRepository,
private readonly clock:
Clock
) {}
}
각 클래스는 자신의 작업에 필요한 의존성만 가진다.
의존성 목록은 단순한 생성자 매개변수 목록이 아니다.
해당 구성요소가 얼마나 많은 외부 책임과 연결되어 있는지를 보여주는 설계 정보다.
NestJS와 같은 서버 프레임워크의 서비스 객체는 여러 요청에서 공유될 수 있다.
데이터베이스 클라이언트처럼 동시 사용을 고려해 설계된 객체는 공유할 수 있다.
@Injectable()
class CouponRepository {
constructor(
private readonly database:
DatabaseClient
) {}
}
하지만 현재 사용자처럼 요청마다 달라지는 값을 공유 서비스에 저장하면 안 된다.
@Injectable()
class CouponService {
private currentUserId:
string | null = null;
setCurrentUser(
userId: string
) {
this.currentUserId = userId;
}
}
동시에 실행되는 다른 요청이 값을 덮어쓸 수 있다.
요청별 값은 메서드의 입력으로 전달하는 편이 안전하다.
async getMyCoupons(
userId: string
) {
return this.repository
.findByRecipientId(userId);
}
의존성의 생명주기도 계약의 일부다.
| 의존성 | 일반적인 생명주기 |
|---|---|
| 데이터베이스 클라이언트 | 애플리케이션에서 공유 |
| 설정 객체 | 애플리케이션에서 공유 |
| 현재 사용자 | 요청별 |
| 트랜잭션 컨텍스트 | 작업 또는 요청별 |
| UI 폼 상태 | 화면 인스턴스별 |
| 테스트 저장소 | 테스트별 |
의존성을 주입할 수 있다는 사실만으로 안전한 것은 아니다.
누가 인스턴스를 만들고 얼마나 오래 공유하는지도 함께 결정해야 한다.
테스트에서는 실제 데이터베이스 대신 메모리 저장소를 사용할 수 있다.
class InMemoryCouponRepository
implements CouponRepository {
private coupons =
new Map<string, Coupon>();
async save(
coupon: Coupon
): Promise<Coupon> {
this.coupons.set(
coupon.id,
coupon
);
return coupon;
}
}
테스트는 빠르고 독립적으로 실행된다.
const repository =
new InMemoryCouponRepository();
const useCase =
new IssueCoupon(
repository,
fakeEventPublisher,
fixedClock,
fakeIdGenerator
);
그러나 대체 구현이 실제 저장소와 다르게 행동하면 테스트가 잘못된 확신을 줄 수 있다.
실제 데이터베이스에는 중복 ID 제약이 있지만 메모리 저장소가 기존 값을 조용히 덮어쓸 수 있다.
this.coupons.set(
coupon.id,
coupon
);
메모리 저장소도 중요한 계약을 지켜야 한다.
if (
this.coupons.has(coupon.id)
) {
throw new Error(
"이미 존재하는 쿠폰 ID다."
);
}
대체 가능하다는 것은 같은 인터페이스를 구현했다는 의미만이 아니다.
성공, 실패, 중복과 같은 중요한 행동도 호환되어야 한다.
async function issueCoupon(
input: IssueCouponInput
) {
return prisma.coupon.create({
data: input,
});
}
쿠폰 발행 규칙이 Prisma의 API와 저장 형식에 연결된다.
async function issueCoupon(
input: IssueCouponInput,
repository: CouponRepository
) {
const coupon =
createCoupon(input);
return repository.save(coupon);
}
비즈니스 코드는 자신에게 필요한 저장 역할에 의존한다.
function canRedeemCoupon(
coupon: Coupon
) {
return (
coupon.recipientId ===
currentUser.id &&
coupon.expiresAt >
new Date()
);
}
결과에 영향을 주는 의존성이 호출부에 나타나지 않는다.
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
) {
// ...
}
판단에 필요한 실행 상황을 명시적으로 전달한다.
interface CouponRepository {
save(
input:
Prisma.CouponCreateInput
): Promise<PrismaCoupon>;
}
인터페이스가 있어도 사용하는 코드는 여전히 Prisma에 의존한다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
서비스가 사용하는 개념으로 계약을 표현한다.
class CouponService {
private repository =
new PrismaCouponRepository();
}
저장소를 변경하려면 클래스 내부를 수정해야 한다.
class CouponService {
constructor(
private readonly repository:
CouponRepository
) {}
}
구성 영역에서 적절한 구현을 선택해 전달한다.
await saveCoupon(coupon);
await sendEmail(coupon);
await updateAnalytics(coupon);
await notifyAdmin(coupon);
하나의 후속 작업 실패가 전체 요청을 실패시키고 응답 시간을 늘릴 수 있다.
핵심 결과와 후속 작업의 관계를 확인한 뒤 필요한 작업만 동기적으로 연결해야 한다.
async save(
coupon: Coupon
) {
return coupon;
}
중복, 저장 실패, 데이터 복원 같은 실제 저장소의 행동이 테스트에서 사라진다.
대체 구현도 테스트에 중요한 계약은 동일하게 표현해야 한다.
의존성은 한 코드가 다른 함수나 모듈을 사용하는 관계다.
const coupon =
await couponRepository.findById(
couponId
);
하지만 실제 서비스에서 의존성은 단순한 호출 관계보다 넓다.
한 구성요소에 의존하면 그 대상의 여러 특성도 함께 영향을 준다.
좋은 의존성 설계는 모든 연결을 제거하는 일이 아니다.
서비스의 구성요소가 협력하려면 의존성은 필요하다.
중요한 것은 그 관계를 명확하게 만들고 변경과 실패가 불필요한 영역까지 퍼지지 않게 하는 것이다.
비즈니스 코드는 자신에게 필요한 역할을 표현할 수 있다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
구체적인 기술은 그 역할을 구현한다.
class PrismaCouponRepository
implements CouponRepository {
// ...
}
의존성을 외부에서 전달하면 사용하는 코드와 구성하는 코드를 분리할 수 있다.
const service =
new CouponService(
prismaCouponRepository
);
숨겨진 의존성은 입력이나 명시적인 계약으로 드러낼 수 있다.
coupon.isExpired(now);
외부 의존성을 연결할 때는 성공뿐 아니라 실패와 지연도 고려해야 한다.
공유 가능한 서비스 객체와 요청별 상태의 생명주기도 구분해야 한다.
결국 의존성을 설계한다는 것은 다음 질문에 답하는 일이다.
이 구성요소가 자신의 책임을 수행하기 위해 무엇을 필요로 하며, 그 대상의 변경과 실패가 서비스의 어디까지 영향을 미치도록 허용할 것인가?
의존성은 다른 코드를 가져다 쓰는 관계가 아니다.
서비스 구성요소가 서로 맺는 약속과 영향의 방향을 정의하고, 변경과 실패가 퍼지는 범위를 설계하는 구조다.