사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
앱 & 라이브러리▾
도메인▾
스칼라▾
model.service.ts
<model>.service.ts는 비즈니스 동작 하나가 처음부터 끝까지 실행되는 곳입니다. document를 불러와 바꾸고, 저장한 뒤, 알아야 할 곳에 알립니다.동작에 document 여러 개, 다른 service, background job, 외부 API, 서버에만 있어야 하는 코드가 필요할 때 이 파일을 엽니다.
어느 파일이 맡는 일인가
할 일
document
*.document.ts
service
*.service.ts
signal
*.signal.ts
문서 하나 바꾸기
상태 변경
✓
story.approve() 같은 chain method가 검증하고, document를 바꾸고, this를 반환합니다.상태 전제 조건
✓
document가 그 변경을 할 수 없는 상태면 chain method가 에러를 던집니다.
비즈니스 동작 실행
여러 문서에 걸친 흐름
✓
document를 불러오고, chain method를 부르고, 저장한 뒤 알립니다.
문서 간 규칙
✓
여러 document를 비교하는 규칙은 여기서
Err를 던집니다.외부 API · 작업 · 서버 전용 코드
✓
주입받은 adapter, signal, env 값으로 다룹니다.
밖으로 공개하기
호출 권한
✓
endpoint의 guard가 접근을 결정합니다.
endpoint
✓
exec는 service method 하나만 호출합니다.✓여기에 둡니다여기가 아닙니다
이 페이지에서 쓰는 말
용어설명
database service
serve(db.<model>, …)로 모델 하나에 묶인 service입니다. 그 모델의 method를 받습니다.plain service
serve("<name>" as const, …)로 만드는, 모델이 없는 service입니다.injection builder
serve()에 넘기는 함수입니다. 이 함수가 반환한 key마다 this의 property가 됩니다.chain method
document 하나를 바꾸고
this를 반환하는 document method입니다. 예: story.approve().hook
_preCreate처럼 create<Model>, update<Model>, remove<Model> 앞뒤로 실행되는 method입니다.Service의 세 가지 형태
모든 service는
serve(…)를 상속하는 class입니다. serve()에 무엇을 넘기느냐에 따라 세 가지 형태 중 하나가 됩니다:데이터베이스 service
serve(db.story, …)모델 하나에 묶입니다.
storyModel, CRUD method, filter마다 method 열네 개를 받습니다.일반(plain) service
serve("base" as const, …)모델이 없습니다. runtime 조정, 예약 작업, 공용 서버 기능, 앱 단위 오케스트레이션에 씁니다.
확장 service
serve(db.user, …, ...user.services)같은 모델을 가진 lib의 service를 섞어 넣고, 그 위에 앱만의 동작을 더하는 database service입니다.
import까지 모두 갖춘 database service입니다:
apps/koyo/lib/story/story.service.ts
db는 값으로,srv는 type으로 import합니다.serve()는 런타임에 모델이 필요하고, service는 type으로만 쓰므로 런타임 import 그래프가 가볍게 유지됩니다.- 주입한 key는 property가 됩니다.
actionLogService는this.actionLogService로 읽습니다. - method는 짧게 둡니다. 불러오고, chain method를 부르고,
return await ….save()로 끝냅니다.
plain service에는 모델이 없습니다. framework의
BaseService가 그 예입니다:pkgs/akanjs/service/base.service.ts
serve()가 주는 것
serve()는 상속할 class를 돌려줍니다. 그 class에 무엇이 이미 들어 있는지는 첫 번째 인자에 따라 다릅니다:받는 것
데이터베이스
serve(db.x, …)
일반
serve("x", …)
모델에서 오는 것
<model>Model
✓
this.storyModel 같은 model adaptor.get<Model> … remove<Model>
✓
자동 생성 메서드에 정리된 CRUD method 여섯 개.
list<Query> … updateOne<Query>
✓
document의 filter마다 method 열네 개.
_preCreate … _postRemove
✓
create, update, remove 앞뒤의 hook.
모든 service에
logger
✓
✓
StoryService처럼 class 이름을 단 Logger.onInit · onDestroy
✓
✓
부팅 때 한 번, 종료 때 한 번 실행.
주입된 property
✓
✓
injection builder가 반환한 모든 key.
...extendServices
✓
✓
builder 뒤에 넘긴 service class를 섞어 넣은 것.
✓들어 있음없음
인자
db.<model>DatabaseModel
database service의 첫 번째 인자입니다.
"<name>" as conststring
plain service의 첫 번째 인자입니다.
option{ enabled?, serverMode? }optional
쓸 때는 두 번째 자리에 둡니다. 아래 Service 옵션을 보세요.
injectBuilder({ service, use, … }) => ({ … })
주입할 property를 반환합니다. 주입 빌더 섹션을 보세요.
...extendServicesServiceCls[]optional
그 service들의 method, 주입, hook을 섞어 넣습니다. Service 확장 섹션을 보세요.
Service 옵션
이 옵션은 어느 프로세스에서 service를 켤지 정합니다.
batch 프로세스는 트래픽 없이 백그라운드 작업을 돌리고, federation 프로세스는 트래픽을 받습니다. 기본 단일 프로세스는 all로 돌기 때문에 거기서는 둘 다 켜집니다.enabledboolean | (() => boolean)기본값 true
false면 service가 켜지지 않습니다. 함수를 주면 처음 읽힐 때 한 번만 실행됩니다.serverMode"batch" | "federation"
SERVER_MODE가 그 값이거나 all일 때만 켜집니다. 둘 다 쓰면 enabled가 우선합니다.자동 생성 메서드
database service는 아래 method를 직접 쓰지 않아도 받습니다. 이름은 모델 이름과
<model>.document.ts에 선언한 filter를 따릅니다.기본으로 붙는 속성
속성설명
<model>Model
자동으로 주입되는 model adaptor입니다. 모델 자체의 method와 filter method를 여기서 부릅니다.
logger
service class 이름을 단 Logger입니다.
CRUD 메서드
메서드설명
get<Model>(id)
id로 document 하나를 불러옵니다. 없으면 에러를 던집니다.
load<Model>(id?)
id로 document 하나를 불러옵니다. 없거나 id가 비어 있으면 null을 반환합니다.
load<Model>Many(ids)
여러 id의 document를 한 번에 묶어 불러옵니다.
create<Model>(data)
_preCreate와 _postCreate를 거쳐 document를 생성합니다.update<Model>(id, data)
_preUpdate와 _postUpdate를 거쳐 patch를 적용하고, 수정된 document를 반환합니다.remove<Model>(id)
remove hook을 거쳐 soft remove(
removedAt 설정)하고, 이어서 cascade를 실행합니다.Filter 메서드
document의 filter 하나마다 method 열네 개가 생깁니다.
<Query>는 filter key의 첫 글자를 대문자로 바꾼 것입니다. filter inRoot는 listInRoot가 됩니다.읽기
메서드설명
list<Query>(...args, option?)
일치하는 document 목록을 가져옵니다.
listIds<Query>(...args, option?)
일치하는 document의 id 목록을 가져옵니다.
find<Query>(...args, option?)
일치하는 document 하나를 찾고, 없으면 null을 반환합니다.
findId<Query>(...args, option?)
일치하는 document 하나의 id를 찾고, 없으면 null을 반환합니다.
pick<Query>(...args, option?)
일치하는 document 하나를 찾습니다. 없으면 에러를 던집니다.
pickId<Query>(...args, option?)
일치하는 document 하나의 id를 찾습니다. 없으면 에러를 던집니다.
exists<Query>(...args)
일치하는 document가 있는지 확인합니다. 있으면 그 id를, 없으면 null을 반환합니다.
count<Query>(...args)
일치하는 document 수를 셉니다.
insight<Query>(...args)
일치하는 document에 대해 모델의 insight(집계)를 계산합니다.
query<Query>(...args)
query를 실행하지 않고 query descriptor 자체를 반환합니다.
마지막 option 인자.
list와 listIds는 { sort, skip, limit, sample, select }를 받고, find, findId, pick, pickId는 여기서 limit만 빼고 받습니다. 나머지는 option을 받지 않습니다.쿼리 단위 쓰기
메서드설명
remove<Query>(...args)
일치하는 document 전부를 원자적 업데이트 한 번으로 soft remove합니다.
removeOne<Query>(...args)
createdAt 기준 가장 최근 document 하나를 soft remove합니다. 한 건짜리 query용이며 큐 소비용이 아닙니다.update<Query>(...args).set(patch)
일치하는 document 전부를 원자적으로 수정합니다. patch는
.set()에 넘기며, chain만으로는 실행되지 않습니다.updateOne<Query>(...args).set(patch)
createdAt 기준 가장 최근 document 하나를 수정합니다. 결과에는 개수만 있고 어느 행인지는 없습니다.

쿼리 단위 쓰기는 hook과 cascade를 건너뜁니다. 원자적 업데이트 한 번으로 끝나므로
_postRemove도 돌지 않고 cascade도 이어지지 않습니다. 둘 중 하나라도 있는 모델은 remove<Model>(id)로 document를 하나씩 삭제하세요.전문 검색
전문 검색은 별도의 method가 아닙니다. query에서
q.search()를 호출하는 filter도 다른 filter와 똑같이 method 열네 개를 만듭니다:apps/koyo/lib/story/
sort: "relevance"는 일치 점수가 높은 순서로 정렬합니다.- 빈 검색어는 아무것도 찾지 않습니다. 비어 있거나 공백뿐인 검색은 전체가 아니라 빈 결과를 돌려줍니다.
Service 확장
앱이 lib 모듈과 같은 이름의 모듈을 선언하면(예:
libs/shared의 user) 앱의 모듈이 lib의 모듈을 대신합니다. serve()에 ...user.services를 펼쳐 넣으면 lib의 동작을 그대로 두고 그 위에 앱의 동작을 더할 수 있습니다.lib/__lib/lib.service.ts는 앱이 lib과 공유하는 모델마다 이런 항목을 하나씩 export합니다:apps/koyo/lib/user/user.service.ts
- lib의 method가 함께 옵니다. lib의
UserService가 정의한 것은 모두this에서 부를 수 있습니다. - hook은 덮어쓰지 않고 쌓입니다. lib의
_preCreate가 먼저, 그다음 앱의 것이 앞 결과를 받아 실행됩니다.onInit도 둘 다 실행됩니다. - 이름이 겹치면 앱의 주입이 이깁니다. 앱에서 선언한 key가 lib의 같은 이름 key를 대신합니다.
- 앱 전용 연동은 여기에 둡니다. 공용 동작은 lib에 두고, GitHub 로그인처럼 이 앱에만 필요한 것은 앱 service에 둡니다.
주입 빌더
serve()에 넘기는 함수가 주입 빌더입니다. helper 일곱 개(database, service, use, signal, plug, env, memory)를 받아 객체를 반환하고, 그 key가 this의 property가 됩니다:apps/koyo/lib/example/example.service.ts
- 주입된 값은 읽기 전용입니다.
memory(…, { local: true })만 쓸 수 있습니다. - key 이름이 곧 연결 정보입니다.
service()의 key는Service로,signal()의 key는Signal로 끝나고,use()의 key는lib/option.ts에 등록한 이름과 같아야 합니다. - 고르는 순서가 있습니다. 다른 모듈은
service(), adapter는plug(),option.ts에 등록된 값만use(), 설정은env()로 받습니다.
주입 종류
값이 어디에서 오는지를 보고 helper를 고릅니다:
helper설명
service<T>()
다른 service이며 lib의 service도 됩니다. key는
Service로 끝나야 하고, 앞부분이 대상 이름입니다.use<T>()
lib/option.ts에서 option.use()로 등록한 값입니다. key가 등록한 이름과 같아야 합니다.signal<T>()
background job을 queue에 넣거나 event를 publish할 server signal입니다. key는
Signal로 끝납니다.plug(Adaptor)
adapt() adapter입니다. 그 role에 구현체가 적용되어 있으면 구현체가 들어옵니다.env(factory)
부팅 때 server env나
process.env에서 만든 값입니다. env("KEY")가 아니라 factory를 넘깁니다.memory(ref, opts)
cache adaptor에 두는 상태이며,
local: true면 instance에 둡니다. 아래에서 자세히 봅니다.database()
이 service의 모델입니다. database service에는 이미
<model>Model로 들어 있습니다.실제 코드의 use()와 plug()
shared lib의 file service는 storage를
use()로, IPFS를 plug()로 받습니다:libs/shared/lib/file/file.service.ts
hook에서 쓰는 env()
factory는
ModulesOptions 타입의 앱 server env를 받아 부팅 때 한 번 실행됩니다:apps/koyo/lib/devProject/devProject.service.ts
memory() 자세히
memory(ref, opts)는 호출이 끝나도 남아 있는 상태를 service에 둡니다. local이 없으면 앱의 cache adaptor에 저장됩니다. option은 다음과 같습니다:localboolean기본값 false
cache 대신 이 instance에 쓰기 가능한 일반 값으로 둡니다.
Map이면 진짜 Map입니다.default
단일 값이 첫
set() 전에 읽는 값이며, 없으면 null입니다. local memory는 이 값으로 시작합니다.of
Map memory의 값 타입으로, scalar나 model class입니다. ref가 Map이면 꼭 필요합니다.ttlnumber (ms)
쓴 값 하나하나가 살아 있는 시간입니다.
set()이 { expireAt }를 직접 주면 그 값이 우선합니다.get(stored) => value
저장된 값(Map이면 항목 값)을 코드가 읽는 모양으로 바꿉니다.
set과 함께만 줍니다.set(value) => stored
get의 반대로, 코드가 쓰는 값을 저장할 값으로 되돌립니다.this.x가 어떤 모양이 되는지는 선언 방식에 따라 다릅니다:선언받는 것
memory(ref, { local: true })
바로 읽고 대입하는 일반 값입니다.
memory(ref)
async method 세 개를 가진 객체입니다.
memory(Map, { of: ref })
async key-value map입니다.
세 가지 모양을 한곳에 모으면 이렇습니다:
apps/koyo/lib/_runtime/runtime.service.ts
- JSON을 직접 만들지 말고 모델을 저장하세요.
memory(Map, { of: cnst.OauthClient })는 constant를 거쳐 직렬화됩니다.Stringmemory에 JSON을 손으로 인코딩해 넣지 마세요. - memory는 선언한 service나 adaptor의 것입니다. 두 service가 모두
token을 선언해도 값은 각자 따로 가집니다. Map에 없는 key를 읽으면undefined입니다.default는 단일 값에만 적용되므로get(key)는??로 감쌉니다. Map 항목은 SQLite든 Redis든 하나씩 따로 만료됩니다.local에는get과set을 쓸 수 없습니다. local memory는 값을 그대로 들고 있습니다.
비즈니스 로직 흐름
service method는 그 method가 수행하는 비즈니스 동작이 그대로 읽혀야 합니다. document를 불러오고, chain method를 부르고, 다른 service와 협력하고, log를 남기고, signal을 queue에 넣는 일을 한곳에서 합니다.
좋아요는 다른 service로 기록한 뒤, 모델이 개수를 셉니다:
apps/koyo/lib/story/story.service.ts
백업은 여러 단계를 거치고, 오래 걸리는 부분은 queue에 넣은 job으로 나중에 실행됩니다:
apps/koyo/lib/dbBackup/dbBackup.service.ts
- 불러오고, 저장하고, 그다음 알립니다. 필요한 document를 모두 불러와 저장한 뒤에야 signal이나 다른 service를 부릅니다.
- 마지막은
return await로 씁니다. 그냥return해도 될 자리여도await를 지우지 않습니다. - 기다리지 않는 호출에는
void를 붙입니다. 일부러 기다리지 않는 호출 앞에void를 쓰면await를 빠뜨린 게 아니라 뺀 것임이 드러납니다. - "허용 안 됨"이나 "없음"은
null또는false로 반환합니다. 그것을 에러로 볼지는 signal이 정합니다.
라이프사이클 훅
hook은 service의
create<Model>, update<Model>, remove<Model> 앞뒤와, 부팅과 종료 때 한 번씩 실행됩니다. 항상 지켜야 하는 규칙이면 hook을, 한 번의 비즈니스 동작이면 일반 method를 씁니다.hook설명
_preCreate(data)
create<Model> 전에 실행됩니다. 생성할 data를 반환하며, 바꿔서 반환해도 됩니다._postCreate(doc)
document가 생성된 뒤 실행됩니다. document를 반환합니다.
_preUpdate(id, data)
update<Model> 전에 실행됩니다. 적용할 patch를 반환합니다._postUpdate(doc)
수정이 끝난 뒤 실행됩니다. document를 반환합니다.
_preRemove(id)
remove<Model> 전에 실행됩니다. 여기서 확인하거나 정리하고, 에러를 던지면 삭제가 멈춥니다._postRemove(doc)
soft remove가 끝난 뒤 실행됩니다. document를 반환합니다.
cascade
cascade field는 대상의 service를 거쳐 삭제하므로, 대상의
_postRemove도 함께 실행됩니다.onInit()
부팅 때 이 service의 주입이 채워진 뒤 한 번 실행됩니다.
onDestroy()
서버가 종료될 때 한 번 실행됩니다.
- 이 hook은 service 자신의 쓰기에서만 실행됩니다.
create<Model>,update<Model>,remove<Model>은 hook을 거치지만, chain의.save()와 쿼리 단위 쓰기는 거치지 않습니다. - 삭제는 정해진 순서로 진행됩니다.
_preRemove, soft remove,_postRemove, 그다음 cascade입니다.
아래 예에서 백업은 같은 브랜치에서 두 번 시작되지 않고, 새 백업은 스스로 archive job을 queue에 넣습니다:
apps/koyo/lib/dbBackup/dbBackup.service.ts


new Error가 아니라 Err를 던지세요. 맨 Error는 호출한 쪽에 "Internal Server Error"로만 전달됩니다. module dictionary의 key를 가리키는 Err를 던지고, 그 key를 dictionary에 [en, ko] 쌍으로 등록하세요:apps/koyo/lib/dbBackup/dbBackup.dictionary.ts
실전 규칙
- 흐름은 service에 둡니다. 여러 model, service, signal, 외부 API를 조합하는 일은 service method입니다.
- document 하나의 변경은 document에 둡니다. chain method로 쓰고, 저장이 필요하면 service에서
.save()를 호출합니다. - 주입 key는 역할대로 이름 짓습니다. service는
Service로, signal은Signal로 끝납니다. - 외부 패키지는
srvkit/에서 감쌉니다. 새로 만들 때는adapt()class로 쓰고plug()로 주입합니다.use()는lib/option.ts에 이미 등록된 값에 씁니다. - lib service는 복사하지 말고 확장합니다. 공용 동작은
...<model>.services로 가져오고, 앱 전용 연동은 앱 service에 둡니다. - 순환 의존은 안 됩니다. 두 service가 서로를 주입할 수 없습니다. 공유하는 동작을 더 작은 service나
srvkit/helper로 옮기세요. - 소유권은 한 번 더 확인합니다. guard가 이미 호출을 걸렀더라도 호출자가 document의 주인인지 service에서 다시 확인합니다. 둘은 서로 독립된 관문입니다.