일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- CI/CD
- JIRA
- yaml-resource-bundle
- oauth2
- Spring
- 티스토리챌린지
- AWS
- 스프링시큐리티
- 액세스토큰
- 프로그래머스
- 소셜로그인
- java
- 메시지
- springsecurityoauth2client
- 재갱신
- 리프레시토큰
- 국제화
- docker
- 도커
- springdataredis
- githubactions
- 스프링
- springsecurity
- 토이프로젝트
- 파이썬
- 스프링부트
- 백준
- 트랜잭션
- 데이터베이스
- 오블완
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 10. 애플리케이션 내부 아키텍처 설계 본문
이번 글에서는 애플리케이션 기능 전반에 대한 설계를 소개해보고자 합니다.
사실 처음에는 프로젝트의 모든 기능을 하나하나 구현해가는 과정을 담으려 했으나, 모든 것을 다루기에는 글의 분량이 지나치게 많아지고 취준 관점에서 시간 비용이 너무 많다는 판단이 들었습니다.
이번 글부터 진행되는 내용은 구체적인 코드 구현에 대한 내용은 어느 정도 생략하고 설계 및 일부 예시 코드 언급 수준으로만 진행하도록 하겠습니다.(다만 실제 코드는 GitHub Repository에 계속 게시됩니다.)
일단 회원 가입기능을 예시로 들어 기능 설계/구현을 해보겠습니다.
1. 이벤트 스토밍
서비스를 운영한다는 것은, 그 서비스를 이용하는 사용자들이 있고 그들에게서 서비스 운영의 대가를 직,간접적으로 받아 수익을 창출하는 것이죠.
개발자의 입장에서는 우리 서비스의 사용자라는 개념을 정의하고 그 개념을 코드의 형태로 녹여내는 작업이 필요합니다.
회원이 있어야 서비스를 운영해나갈 수 있고, 결국 어떤 기능을 구현할 때 저는 회원을 먼저 고민하게 됩니다.
이번 글에서는 회원에 관련된 기능을 구상하고 관련 기능을 설계한 뒤 기능을 만들어가보도록 하겠습니다.
일단 사용자가 있어야 서비스가 돌아가므로 회원가입 기능부터 설계해보겠습니다.
그런데, 회원 가입이란게 생각해보면 한 API 로 가입을 끝낼 수 있는게 아니라 여러 절차가 필요합니다.
웹 사이트에서 회원가입을 할 때 보통 회원 관련된 정보를 한번에 전부 작성하고 제출하는 일은 거의 없죠.
1. 사용자 아이디가 중복되는지 확인
2. 닉네임이 중복되는지 확인
3. 이메일이 중복되는지 확인
4. 이메일 검증을 위한 이메일 발송 요청
5. 4에서 발송된 이메일에 전달된 코드를 기반으로 이메일 유효성 입증
6. 실제 회원가입
보통 이런 6가지 과정을 거쳐서 회원 가입이 이루어집니다.
하지만 협업 과정에서 제가 말한 이런 6가지 과정이 필요하다는 결론이 나올 때까지의 과정은 생각보다 복잡합니다.
어떤 프로젝트를 만드는 과정에 개입하는 사람들은 백엔드 개발자만 있는 것이 아니기 때문입니다.
서비스에 대한 비즈니스적인 결정권을 지닌 비즈니스 소유자가 계실 수 있고,
우리가 서비스를 통해 다루고자하는 주제, 도메인에 대한 전문적인 지식을 가진 전문가 분도 계시고
백엔드 개발자, 프론트엔드 개발자, 모바일 개발자, ... 등등등 다양한 역할을 가지신 분들이
서로 요구사항에 대해 논의하는 과정이 필요합니다.
하지만 각각이 서로 하나의 주제를 처음 논의하는 순간에서는 의견이 서로 맞지 않습니다.
서로 각각 도메인에 관한 이해도, 지식, 용어가 맞지 않기 때문입니다.
그래서 등장한 방법론이 이벤트 스토밍(Event Storming)이라는 기법입니다.
Alberto Brandolini 가 도메인 주도 설계 (DDD) 맥락에서 만든 것으로 알려져 있습니다.
실제 서비스 개발에 관한 모든 이해당사자들이 한 자리에 모여서
하얀 벽지, 포스트잇, 마커 등을 가지고 자신들이 생각하는 도메인 지식을 기반으로 어떤 기능을 만들 것인지 각각의 기능들의 유기적인 연관관계 등을 논의하고 서로 도메인에 대한 지식을 공유합니다.
제각각 도메인에 대한 이해가 다르기 떄문에 처음에는 용어가 다르지만 상호간의 협의과정을 거치면서 용어를 통일시키고 개념을 하나로 일치시켜가는 과정입니다.
이벤트 스토밍 진행자는 참가자들을 모아두고, 참가자들에게 특정 색상의 포스트잇에 무엇을 작성해야하는 지 설명하고 자유롭게 작성하게 지시하여 이벤트 스토밍을 진행합니다. 일단 제가 사용한 개념들은 다음과 같습니다. (실제 이벤트 스토밍 표준과는 좀 다릅니다.)
- 도메인 이벤트(Event): 주황색. 시스템에서 발생하는 중요한 이벤트. 주로 과거형으로 표현
- 커맨드(Command): 파란색. 이벤트를 트리거하는 명령
- 파라미터: 초록색. 각 명령이 실행될 때 어떤 파라미터가 필요할 지
- 액터(Actor) : 진한 노란색. 누가(사용자가? 시스템이?) 그 작업을 일으키는 지
- 정책(Policy): 여기서는 빨간색. 이벤트 일어난 결과 다른 커맨드를 시작하는 시나리오
- 애그리거트(Aggregate): 연한 노란색. 도메인 이벤트와 커맨드에 의해 관리되는 데이터
- 외부 시스템(External System): 연동이 필요한 외부 시스템 또는 프로세스(예: 구글 이메일)
- 그 외(E.T.C) : 분홍색. 명령 과정에서 주의할 제약 사항이나 논의의 여지가 있거나 하는 지점
전체적인 기능의 흐름도를 나타내봤습니다.
최종적으로는 이런 흐름도가 완성되게 되는데, 협업을 하는 과정이면 프로젝트 진행 도중 다른 팀원들과 이 화면을 놓고 서로 도메인에 대한 의견을 일치시키고, 수정시켜야한다면 함께 이 흐름도를 수정합니다.
이 방법은 이벤트 스토밍의 정석적인 방법은 아니고 구글에 검색해보시면 될 듯 합니다. 위에서 사용한 용어도 실제랑 조금 다를 수 있습니다.
저는 팀 프로젝트를 하지 않고 개인 프로젝트를 하는 지라 혼자서 간단하게 만들어봤습니다.
위의 상황을 기반으로 하여 애플리케이션을 단계적으로 설계해보려고 합니다.
2. (예시) 회원가입 기능 - 내부 아키텍처 설계
일단 전반적인 애플리케이션 내부 아키텍처 구상입니다.
실제 구현될 코드는 저기서 컴포넌트가 더 많아지긴 하는데, 일단 1차적인 흐름을 보기는 이 그림이 적절할 것 같아서 이렇게 보여봤습니다.
일단 크게 3종류의 애플리케이션 모듈이 사용됩니다.
1. api 모듈
2. application 모듈
3. domain 모듈
api 모듈에는 컨트롤러가 위치해있습니다.
클라이언트의 Http 요청을 받아서, 요청 객체로 바인딩합니다.
(이 작업은 Spring을 사용한다면 Spring 이 대신해줍니다만 이런 기능이 없는 언어나 프레임워크의 경우 개발자가 직접 코드를 작성해야합니다.)
사용자의 요청을 받아서, UseCase 쪽에 처리를 위임합니다.
그리고 그 결과를 받아서 사용자에게 내려줄 응답으로 변환하는 역할을 담당합니다.
이 계층은 Http 메시지와 밀접한 계층이니만큼 스프링 MVC와 관련된 기술 코드를 사용할 수 있고, 애플리케이션 기능 호출은 애플리케이션의 유즈케이스를 통해서만 가능합니다. 도메인 계층의 모델이나 기능은 모르도록 설계되어있습니다.
애플리케이션 모듈은 사용자의 요청에 대한 실질적인 애플리케이션 비즈니스 로직 처리를 담당합니다.
실제 UseCase 를 구현한 ApplicationService를 두고 실제 애플리케이션 비즈니스 로직 처리를 합니다.
1. CommandMapper : 사용자 요청 객체(Request) 를 분석하여 유효성에 맞는 지 1차 검증 후 Processor에서 바로 사용할 수 있는 명령 객체(Command)로 변환합니다.
2. Processor: 실제 비즈니스 로직 구현이 일어나는 곳입니다. 여러가지 DomainService를 의존하고, 이들에게 도메인 규칙/기능 처리를 명령하고 통합하여 비즈니스 로직을 구현합니다.
3. TransactionRunner: 트랜잭션 경계를 설정하고, 실행하기 위한 인터페이스입니다.
4. EventPublisher : 앞서 진행한 작업 결과, 이벤트를 발행시켜야할 경우 이것을 통해 이벤트를 발행시킵니다.
Domain 모듈은 우리 서비스에서 해결하고자하는 사용자의 문제의 본질적인 개념/기능을 다루는 곳입니다.
우리 서비스에서 회원 개념을 다룬다면 회원과 관련된 클래스를 이곳에 정의하고 이곳에 개념 및 원시적인 기능, 규칙 등을 두어 관리합니다.
예를 들어 회원의 아이디는 4글자 이상 10자 이하여야하고 특수문자는 허용하지 않는다 와 같은 제약이 필요하다면 이 곳에서 관리되도록 합니다.
회원을 생성하거나, 삭제, 수정하는 기본적인 개념들은 주로 이곳에 모여서 관리됩니다.
(이러한 개념, 기능은 애플리케이션 기능보다 좀 더 원자적인 행위나 개념을 지칭합니다.)
DomainService를 통해서 도메인 모듈의 기능 호출을 할 수 있고 구현체는 주로 이곳에 두거나, 외부모듈에서 구현체를 둡니다. 저는 이러한 역할 계층을 DomainService 라고 계층 명을 짓긴 했는데 반드시 DomainService라고 클래스 이름을 짓지는 않고 있습니다. 실제 코드에서는 MemberCreator, MemberFinder 등의 이름으로 지었습니다.
도메인 모듈에서는 특정 기술을 직접적으로 호출하지 않는 것이 원칙입니다. (api, application 모듈도 사실 특정 기술 호출을 직접적으로 하지 않도록 하긴 했습니다.) 특정 기술 사용이 필요하다면 외부모듈 연결 인터페이스를 두고, 외부 모듈에서 해당 기술 코드를 호출해서 기능을 구현하도록 합니다.
3. 내부 아키텍처 설계 - 모듈간 관계
기능을 구현하면서 모듈은 아마 이런식으로 구성하지 않을까 싶습니다.
api 모듈들은 공통적으로 api-core 모듈을 의존하고 각각에 대응하는 애플리케이션 모듈을 호출합니다.
application 모듈들은 공통적으로 application-core 모듈 및 domain-core 모듈을 의존하고, 각각 필요한 도메인 모듈을 의존하게 할 예정입니다.
예를 들어 그림을 보면,
api-member 모듈은 api-core 모듈을 의존하고 application-member 모듈을 의존합니다.
appliaction-member 모듈은 application-core 모듈을 의존하고, domain-core 및 domain-member 모듈을 의존합니다.
domain-member 모듈은 domain-core 모듈을 의존합니다.
domain-core 모듈은 어떤 의존성도 가지지 않습니다.
다만 이 개념은 실제 제가 코드를 구현해나가다가 좀 불편하거나 맞지 않다 생각되면 고쳐볼 생각입니다.
(참고로 지금 저는 auth 모듈도 같이 고려해서 설계했는데 이건 향후 로그인 기능을 구현할 때 어떻게 할지도 함께 고민하면서 그려본 것입니다.)
그 외로, 외부 기술 의존성이 있는 모듈들은 external 모듈을 만들어서 구현체를 따로 관리할 것입니다.
예를 들어 사용자 정보를 db에대 저장해야한다면, 사용자 정보 저장에 관련된 기능은 domain 쪽에 두지 않고
domain 에 선언해둔 인터페이스의 구현체를 external 모듈을 만들어서 그곳에 구현체를 두는 것입니다.
domain 이나 application 계층에서는 domain 에 위치한 인터페이스를 의존하면서 코드를 작성하고 그것이 실제 어느 모듈에서 구현체가 있는 지에 관심을 두지 않고 기능을 구현합니다.
오직 이런 구체적인 기술 구현 모듈에 대한 의존성은 container 모듈에서만 담당합니다
rootProject.name = "board-system"
include(
// 설정 최종 구성 및 실행
"board-system-container",
// 공용 모듈
"board-system-core",
"board-system-logging",
// api
"board-system-api:api-core",
"board-system-api:api-member",
"board-system-api:api-deploy",
// application
"board-system-application:application-core",
"board-system-application:application-member",
// domain
"board-system-domain:domain-core",
"board-system-domain:domain-member",
// external : 외부 기술 의존성
"board-system-external:external-message",
"board-system-external:external-db",
"board-system-external:external-redis",
"board-system-external:external-exception-handle",
"board-system-external:external-email-sender"
)
이렇게 해서 최종적으로 이번 글 시점까지 정의된 모듈들입니다. 다음 모듈들을 추가적으로 정의했습니다.
api 계층 : "api-member"(회원 api)
application 계층 : "application-member"(회원 애플리케이션), "application-core"(애플리케이션 공통 의존성)
domain 계층 : "domain-core"(도메인 공통 의존성), "domain-member"(회원 도메인)
external 계층 : "external-db"(데이터베이스 접근), "external-redis"(레디스 접근), "external-email-sender"(이메일 발송)
4. 인터페이스/구현체 분리
제가 작성하는 코드들은 인터페이스와, 구현을 분리하는 경향이 강한 편입니다.
특히나 도메인이나 애플리케이션 로직에서 특정 기술에 대한 의존성이 필요하다? 그런 경우에는 아예 구현체를 외부 모듈에다 작성합니다.
예를 들어 회원 도메인을 저장할 때, 회원을 저장하는 구체적인 기술부분은 External 계층의 모듈쪽으로 분리해서 관리해요.
애플리케이션 계층의 구현 관점에서는 애플리케이션 계층과 도메인만 고려하면서 코드를 작성하고,
도메인 계층 구현 과정에서는 도메인 개념만을 고려하면서 코드를 작성하는 것이 편했어요.
4.1 특정 기술 종속성 문제 분리 관점
물론 이렇게 컨트롤러-서비스-리포지토리 구조처럼, 인터페이스를 제거하여 관리하는 방법을 많이들 사용합니다.
이렇게 할 경우 관리해야할 코드가 많이 줄어들어서 초기 개발 관점에서는 좀 더 개발 비용이 줄어드는 장점이 있습니다.
(초기 개발 관점에서 이게 장점으로 작용하는 경우가 많습니다.)
다만 이렇게 구현체를 직접적으로 의존하는 행동이 허용된다면 예를 들어 애플리케이션 서비스 개발자는 JPA 의 리포지토리를 의존한 코드를 작성할 수 있게 됩니다. 도메인 개념 코드에도 JPA 관련 코드도 작성하게 될 것이며, 애플리케이션 서비스 구현 관점에서, JPA 기술을 염두한 코드를 작성하게 될 수 있습니다. (예: 영속성 컨텍스트 초기화 등...)
실제 이전에 진행했던 프로젝트에서는 애플리케이션 서비스쪽에서 문제가 터졌었는데 그것이 JPA 문제였었던 경우도 있습니다. 그래서 JPA 문제를 해결하기 위해 애플리케이션/도메인 코드를 수정해야했고요. 그리고 애플리케이션 로직을 짤때도 JPA 를 염두해두고 코드를 짜게 되서 코드 구조가 갈 수록 JPA 종속성이 짙어지는 느낌을 받았습니다.
4.2 테스트 코드 실행 성능 관점
테스트코드 실행 성능 관점에서도 이야기를 해보고자 합니다.
예를 들어 RegisterMemberController 를 테스트를 하려면 RegisterMemberApplicationService 의존성이 필요합니다.
그런데 RegisterMemberApplicationService는 MemberRepository를 의존하고 있습니다.
컨트롤러 자체만 테스트를 하고 싶은데 RegisterMemberApplicationService, MemberRepository 코드를 모두 가져와서 테스트하는건 너무 테스트 코드가 무거워지는 문제가 있습니다. 이건 통합테스트 영역으로 가게 되죠.
그래서 보통 많이들 여기서 Mock 라이브러리를 사용해서 애플리케이션 서비스의 가짜 구현 설정 코드를 작성합니다.
이렇게 하면 애플리케이션 서비스가 어떤식으로 구현되있는지 관심을 가지지 않고 컨트롤러 단위 테스트를 하기 용이합니다.
하지만 이렇게까지만 할 경우 Mock 라이브러리 사용으로 인해 해당 테스트 코드 실행 성능이 크게 차이가 생기더라고요. ms 단위로 실행되던 테스트 코드가 Mock을 함께 사용할 경우로 초 단위로 실행되는 식이 되기 때문에 테스트 실행 관점에서 계속 끊기는게 불쾌한 경험으로 다가왔습니다.
아무래도 테스트 런타임에 실행하면서 Mock 관련 추가적인 보조 인스턴스 생성 등도 함께 일어나서 그런 것 같습니다.
(+실제 Mock 설정 코드를 테스트코드에 길게 나열하는 경우가 많아지는데 이렇게 할 경우 테스트코드 가독성이 안 좋아지는 문제도 있었습니다.)
직접 인터페이스, Fixture 코드를 따로 라이브러리로 분리해두면 컨트롤러 테스트 실행이 눈에 띄게 빨라지는 경향이 있습니다. Mock을 쓸 때는 런타임에 인스턴스를 생성하면서 부가적인 인스턴스도 생성하는 것과 다르게, Fixture 방식은 제가 작성한 구현체를 바로 생성해서 사용하기 때문에(컴파일 타임에 이미 구현체 코드도 작성되어 있음) 속도가 더 빠른게 아닐까 생각됩니다.
위 두가지 편의성 관점에서 만족도가 좋아서 저는 가능한 인터페이스와 구현체를 분리하려고 합니다.
5. 인터페이스를 먼저 커밋해두자
이제 각 기능의 개발의 시간적 순서를 생각해보도록 겠습니다.
어떤 기능의 컴파일 의존성 방향이 A->B->C->D 순으로 이루어집니다.
많은 분들은 의존성의 방향이 이렇게 되어있는 것을 보니 이런 생각을 하실 수 있을 것 같아요.
"D가 의존성의 끝이 있군? D를 구현하고, C를 구현하고, B를 구현하고, 그 다음에는 A를 구현하면 되겠다"
그렇게 결정을 내리고 이 작업들을 혼자서 순서대로 진행하실겁니다.
그런데 다른 팀원이 자신이 하던 작업을 마무리 짓고 여유가 생겨서 제 api 기능 구현을 도우려고 한다고 생각해보겠습니다.
저는 열심히 D부터 코드를 작성하고 작성하고 있습니다. 아직 커밋은 되지 않았습니다.
"제가 도와드리려고 하는데 뭐 할거 있나요?"
누군가 와서 뭐 도와드릴 만한 거 없냐고 여쭤보십니다. 이런 상황에서 저는 D에 대해 작성하지 않았으니 다른 개발자 입장에서는 D를 의존한 코드를 작성할 수 없고 당장 돕기 힘들어지겠죠.
혹은 이 개발자분은 A나 B는 구현할 수 있지만 C를 구현하는데 필요한 기술력이 아직 부족해서 C 개발에 투입되기 힘든 상황일 수 있습니다.
근데 여기서 약간의 수고를 더해보면 좀더 기능 개발이 유연해질 수 있습니다.
인터페이스에 해당하는 부분들을 모두 먼저 작성해서 커밋을 올려두고 시작하면
D를 개발하는 개발자는 D 인터페이스에 해당하는 구현체를 작성하고
새로 개발을 도우러 온 인력은 D 구현 시기와 관계없이 A,B,C 중 하나를 선택해서 구현할 수 있습니다. C를 개발하는 기술력이 부족하다면 A, B 중 하나를 선택해서 개발하면 되겠죠?
4명의 인력이 투입된다면 A,B,C,D 를 동시에 4명이 개발 할 수 있어서 개발 시간이 더 줄어들 것 같습니다.
그래서 이번 글에서는 각 모듈에서 필요한 인터페이스만 간단하게 정의할 예정입니다.
내부적으로 구현에 사용하는 기능들은 이어지는 글에서 하나씩 구현하도록 하죠.
(물론 저는 협업자가 없어서 모든것을 제가 순서대로 구현해야하긴 합니다.)
실제로 아예 JIRA의 이슈 역시, 모듈간 계약 인터페이스 작성에 초점을 두고 이슈를 작성했습니다.
서두가 정말 길었는데 여기서부터는 모든 코드는 못 다루더라도 어떤 코드를 어떤 이유로 작성했는지 설명해보겠습니다.
6. 애플리케이션 개발에 앞서 필요한 부가기능
애플리케이션 개발에 앞서 부가적인 기능 몇가지를 작성해두고 가겠습니다.
6.1 멀티모듈과 마커 어노테이션
스프링 애플리케이션을 실행할 때 스프링 빈을 등록해야하는데요.
Spring Boot 의존성이 있는 모듈들에서는 @Component, @Repository, @Controller, @Configuration, @Bean 등을 사용해서 빈을 등록하면 됩니다.
하지만 Spring Boot 의존성이 굳이 필요 없는 모듈들이 있습니다.
예를 들어 도메인 서비스, 애플리케이션 서비스와 같은 모듈에서는 굳이 스프링 의존성이 필요 없죠. 이것을 빈으로 등록할 방법이 필요한데, 위의 스프링 어노테이션이 없으면 빈 등록이 힘듭니다. 스프링 빈 등록만을 위해 무거운 스프링 의존성을 추가해줘야할 지는 의문입니다.
그런데 방법이 없는건 아닙니다.
커스텀한 어노테이션을 저희 코드에 작성해두고 활용하면 됩니다.
모든 모듈이 공통적으로 사용하는 core 모듈에 어노테이션을 선언합니다.
package com.ttasjwi.board.system.core.annotation.component
/**
* 애플리케이션에서 싱글톤으로 관리하는 컴포넌트임을 나타내는 마커 어노테이션입니다.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class AppComponent
AppComponent 어노테이션을 정의했습니다.
우리서비스의 독자적 컴포넌트 어노테이션으로서, @Component 와 비슷한 목적으로 사용하기 위한 어노테이션입니다.
package com.ttasjwi.board.system.core.annotation.component
/**
* 도메인 서비스를 나타내는 마커 어노테이션입니다.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@AppComponent
annotation class DomainService
그리고 위와 같이 컴포넌트의 역할을 어노테이션 이름을 통해 명시하고 싶다면 DomainService, ApplicationService 등 커스텀 어노테이션을 작성하고, 여기에 @AppComponent 를 함께 달아주면 됩니다.
이렇게 할 경우 스프링은 위 어노테이션을 읽고 @AppComponent 가 함께 포함된 어노테이션으로 인식하게 됩니다.(메타 어노테이션 기능. 자바 사양 아님)
package com.ttasjwi.board.system
import com.ttasjwi.board.system.core.annotation.component.AppComponent
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.FilterType
@ConfigurationPropertiesScan
@ComponentScan(
includeFilters = [ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = [
AppComponent::class,
]
)]
)
@SpringBootApplication
class Main
fun main(args: Array<String>) {
runApplication<Main>(*args)
}
컨테이너의 Main 클래스의 설정을 살짝 바꿔줍니다. @ComponentScan 설정을 통해, @AppComponent 어노테이션이 달린 클래스 역시 컴포넌트 스캔의 대상으로 삼도록 합니다.
이렇게 하면, @DomainService, @ApplicationService 등의 어노테이션 역시 컴포넌트 스캔 대상이 됩니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.model.Username
import com.ttasjwi.board.system.member.domain.service.UsernameCreator
@DomainService
internal class UsernameCreatorImpl : UsernameCreator {
override fun create(value: String): Result<Username> {
TODO("Not yet implemented")
}
}
이렇게 하면 향후 도메인 모듈의 클래스를 스프링 빈으로 등록하기 위해 스프링 어노테이션을 작성할 필요 없이 저희의 커스텀 어노테이션을 통해 스프링 빈으로 등록할 수 있습니다.
6.2 시간
val currentTime = ZonedDateTime.now()
애플리케이션 로직을 작성하다보면, 현재 시간이 필요한 경우가 많습니다.
생성시간, 수정 시간, ... 등등을 저장하고 관리해야할 일이 많은데요.
위와 같은 ZonedDateTime.now() 방식으로 현재 시간을 바로 가져오는 방식은 기능은 제대로 구현되겠지만 테스트가 어려워지는 단점이 있습니다. 매 순간 현재 시간이 계속 변화하기 떄문에 특정 시간값이 의도한 대로 생성될 것인지 등에 대한 테스트가 어려워지는 문제가 있어요.
그래서 저는 core 모듈에 TimeManager 인터페이스/구현체를 작성하는 방식을 쓰기로 했습니다.
package com.ttasjwi.board.system.core.time
import java.time.ZonedDateTime
interface TimeManager {
fun now(): ZonedDateTime
}
현재 시간을 반환하는 역할을 TimeManager 인터페이스로 정의합니다.
package com.ttasjwi.board.system.core.time.impl
import com.ttasjwi.board.system.core.annotation.component.AppComponent
import com.ttasjwi.board.system.core.time.TimeManager
import java.time.ZonedDateTime
@AppComponent
class TimeManagerImpl : TimeManager {
override fun now(): ZonedDateTime {
TODO("Not yet implemented")
}
}
이렇게, TimeManagerImpl 역시 정의하고 빈으로 등록합니다.
지금 이슈에서는 구체적인 구현체를 작성하는 작업은 하지 않을 것이기 때문에 일단 구현은 하지 않았습니다.
이곳에서 ZonedDateTime.now() 를 호출해서 기능을 구현할 것 같습니다.
val currentTime = timeManager.now()
이제 사용하는 측에서는 TimeManager 컴포넌트를 통해 시간을 가져오게 하면 되는데요.
이렇게 하면 런타임에 실제로 주입되는 TimeManger는 실제로 ZonedDateTime.now() 를 반환하는 TimeManagerImpl 이 되게 하면서
테스트 코드 작성시에는 가짜 시간(우리가 설정한 고정된 시간)을 반환하는 TimeManager 를 대체해서 사용하면 테스트가 편해집니다.
6.3 트랜잭션 실행자(TransactionRunner)
application 계층에서 도메인 기능을 실행하는 과정에서, 데이터의 정합성 보장을 위한 트랜잭션 관리가 필요하죠.
보통 많은 분들이 트랜잭션 처리를 위해 스프링이 제공하는 @Transactional 어노테이션을 많이 사용합니다.
하지만 애플리케이션 메서드에 @Transactional 을 달게 될 경우 해당 코드는 스프링 트랜잭션 기술을 필요로 하는 문제가 있습니다. 그렇지만 트랜잭션 개념은 필요하긴 해요.
고민 끝에 제가 트랜잭션을 해결하기 위해 사용하기로 한 방법은 이렇습니다.
package com.ttasjwi.board.system.core.application
interface TransactionRunner {
fun <T> run(function: () -> T): T
fun <T> runReadOnly(function: () -> T): T
}
모든 application 계층에서 의존하는 application-core 에 TransactionRunner 인터페이스를 선언합니다.
실행되면 T를 반환하는 함수(java에선 Supplier 라고 보시면 될 듯합니다.) 를 전달받아
트랜잭션에서 함수를 대신 실행하는 역할을 합니다.
package com.ttasjwi.board.system.core.application
import com.ttasjwi.board.system.core.annotation.component.AppComponent
@AppComponent
class TransactionRunnerImpl : TransactionRunner{
override fun <T> run(function: () -> T): T {
TODO("Not yet implemented")
}
override fun <T> runReadOnly(function: () -> T): T {
TODO("Not yet implemented")
}
}
그리고 구현체는 external-db 모듈(데이터베이스 및 트랜잭션 처리)에 둘 예정입니다. 여기서 @Transactional 또는 TransactionManager 코드를 사용해서 트랜잭션 처리를 구현하면 될듯 해요.
이렇게 하면 사용하는 측에서는 TransactionRunner 에 트랜잭션 처리가 필요한 함수를 전달하고 트랜잭션 처리를 위임하면 돼요. 사용례는 이후 글들에서 애플리케이션 서비스 구현시 설명하겠습니다.
6.4 미구현 예외 (NotImplementedError) 처리
스프링 애플리케이션에서는 어떤 컴포넌트 의존성을 필요로하는데 그 컴포넌트가 실제 빈으로 등록되어 있지 않으면 애플리케이션 실행 시 에러가 발생합니다. 이를 방지하기 위해 구현체를 일단 등록이라도 해야합니다.
코틀린, Intellij 환경 기준으로 인터페이스 구현체를 자동완성 기능을 통해 만들면
package com.ttasjwi.board.system.core.application
import com.ttasjwi.board.system.core.annotation.component.AppComponent
@AppComponent
class TransactionRunnerImpl : TransactionRunner{
override fun <T> run(function: () -> T): T {
TODO("Not yet implemented")
}
override fun <T> runReadOnly(function: () -> T): T {
TODO("Not yet implemented")
}
}
이렇게 TODO(...) 가 실행되기만 하는 구현체가 자동 완성됩니다.
여기까지만 작성하면 실제 구현은 나중에 하면서, 구현체 클래스를 만들어두는 장점이 생기는데요.
TODO 함수 는 무슨 작업을 할까요?
TODO 는 반환 타입이 Nothing 이면서, 예외를 던지는 역할을 합니다.
NotImplementedError 라는 코틀린 예외를 던지고 있으며 이것은 Error 의 자손입니다.
즉 TODO
그런데 DispatcherServlet 은 Exception이 아닌 Throwable 의 자손들은 ServletException 으로 감싸서 예외처리를 합니다.
NotImplementedError 를 처리하는 로직을 @ExceptionHandler(NotImplementedError) 방식으로 처리할 수 없다는 뜻입니다. 이 부분을 확인하여 예외처리를 해야합니다.
/**
* 커스텀 예외가 아닌 모든 예외들은 기본적으로 여기로 오게 됩니다.
* 커스텀 예외가 아닌 예외들은 커스텀 예외로 감싸거나 다른 ExceptionHandler 를 통해 처리할 수 있도록 만들어야 합니다.
* 여기로 온 예외는 모두 500 예외로 나가게 됩니다.
*/
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ErrorResponse> {
val cause = e.cause
if (cause is NotImplementedError) {
return handleNotImplementedError(cause)
}
log.error(e)
return makeSingleErrorResponse(
errorStatus = ErrorStatus.APPLICATION_ERROR,
errorItem = makeErrorItem(code = "Error.Server", source = "server")
)
}
// 생략
private fun handleNotImplementedError(e: NotImplementedError): ResponseEntity<ErrorResponse> {
log.error(e)
return makeSingleErrorResponse(
errorStatus = ErrorStatus.NOT_IMPLEMENTED,
errorItem = makeErrorItem(code = "Error.NotImplemented", source = "server")
)
}
private fun makeSingleErrorResponse(
errorStatus: ErrorStatus,
errorItem: ErrorResponse.ErrorItem
): ResponseEntity<ErrorResponse> {
return makeMultipleErrorResponse(errorStatus, listOf(errorItem))
}
private fun makeMultipleErrorResponse(
errorStatus: ErrorStatus,
errorItems: List<ErrorResponse.ErrorItem>
): ResponseEntity<ErrorResponse> {
val commonCode = "Error.Occurred"
return ResponseEntity
.status(resolveHttpStatus(errorStatus))
.body(
ErrorResponse(
code = commonCode,
message = messageResolver.resolveMessage(commonCode),
description = messageResolver.resolveDescription(commonCode),
errors = errorItems
)
)
}
private fun makeErrorItem(
code: String,
args: List<Any?> = emptyList(),
source: String,
): ErrorResponse.ErrorItem {
return ErrorResponse.ErrorItem(
code = code,
message = messageResolver.resolveMessage(code),
description = messageResolver.resolveDescription(code, args),
source = source
)
}
private fun makeErrorItems(
exceptions: List<CustomException>
): List<ErrorResponse.ErrorItem> {
return exceptions.map {
makeErrorItem(
code = it.code,
args = it.args,
source = it.source,
)
}
}
Exception 을 처리하는 예외처리기 메서드 내에서, 분기문을 추가하여 cause 가 NotImplementedError 인 경우
handleNotImplementedError 메서드를 호출하도록 기능을 추가했습니다.
Error:
# 생략
NotImplemented:
message: "미구현 기능"
description: "요청하신 기능은 현재 미구현 상태입니다. 해당 기능은 개발 중이며, 가까운 시일 내에 추가될 예정입니다."
그리고 새로운 code 를 추가지정했으므로 메시지 파일도 추가적으로 작성했습니다.
7. 회원 Api 모듈 인터페이스 작성
dependencies {
implementation(project(":board-system-api:api-core"))
implementation(project(":board-system-application:application-member"))
implementation(Dependencies.SPRING_BOOT_WEB.fullName)
implementation(Dependencies.KOTLIN_JACKSON.fullName)
}
회원 Api 모듈부터 작성해보겠습니다.
이 모듈은 api-core 와 application-member 의존성을 가지며, 컨트롤러를 위치시킬 곳이므로 spring-boot-starter-web 등 웹에 필요한 의존성을 가지게 합니다.
컨트롤러들을 모아두는데, 저는 개인적으로 api들은 한 컨트롤러마다 할당하는 것을 선호해서 각각을 컨트롤러로 작성했습니다.
- EmailAvailableController : 이메일 사용가능 여부
- UsernameAvailableController: 사용자 아이디(username) 사용가능 여부
- NicknameAvailableController: 닉네임 사용가능 여부
- EmailVerificationStartController : 이메일 검증 시작(이메일 실제 발송하는 부분)
- EmailVerificationController : 이메일 검증
- RegisterMemberController: 회원 가입
@RestController
class EmailVerificationStartController {
@PostMapping("/api/v1/members/email-verification/start")
fun startEmailVerification(): ResponseEntity<SuccessResponse<EmailVerificationStartResponse>> {
TODO("Not yet implemented")
}
}
data class EmailVerificationStartResponse(
val verificationStartedResult: VerificationStartedResult
) {
data class VerificationStartedResult(
val email: String,
val codeExpiresAt: ZonedDateTime
)
}
컨트롤러는 반환타입을 ResponseEntity 정도로만 정의하고 어떤 타입의 데이터를 반환할 지 정도만 정의해뒀습니다.
구현은 이후의 글에서 하도록 하겠습니다.
@DisplayName("EmailAvailableController 테스트")
class EmailAvailableControllerTest {
private lateinit var emailAvailableController: EmailAvailableController
@BeforeEach
fun setup() {
emailAvailableController = EmailAvailableController()
}
@Test
@DisplayName("이 api는 현재 미구현 상태이다.")
fun test() {
// given
// when
// then
assertThrows<NotImplementedError> { emailAvailableController.checkEmailAvailable() }
}
}
테스트 코드도, 구현체를 아직 작성하지 않은 상황인지라 작성하는건 크게 의미 없긴한데, 미구현 예외가 발생한다는 것 정도만 테스트 해뒀습니다.
8. 회원 Application 모듈 인터페이스 작성
dependencies {
implementation(project(":board-system-application:application-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-domain:domain-core"))
}
application-member 모듈은 application-core, domain-core, domain-member 모듈에 대해 의존성을 가지게 했습니다.
진입 인터페이스인 usecase 들, 그리고 구현체인 application-service 코드들을 이곳에 두었습니다.
package com.ttasjwi.board.system.member.application.usecase
interface EmailAvailableUseCase {
fun checkEmailAvailable(request: EmailAvailableRequest): EmailAvailableResult
}
data class EmailAvailableRequest(
val email: String?
)
data class EmailAvailableResult(
val email: String,
val isAvailable: Boolean,
val reasonCode: String,
)
유즈케이스는 Request 객체 를 받아서 Result 객체를 반환하는 구조로 계약을 명시해뒀습니다.
이 request 객체는 Controller 쪽에서 파라미터 바인딩을 통해 갖고 있게 하고 그대로 command 로 전달하도록 할 예정입니다.
package com.ttasjwi.board.system.member.application.service
import com.ttasjwi.board.system.core.annotation.component.ApplicationService
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableResult
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableUseCase
@ApplicationService
internal class EmailAvailableApplicationService : EmailAvailableUseCase {
override fun checkEmailAvailable(request: EmailAvailableRequest): EmailAvailableResult {
TODO("Not yet implemented")
}
}
ApplicationService 들은 UseCase 를 구현하도록 하며, 이 구현은 뒤의 글에서 다룰 예정입니다.
9. Domain-core 모듈
Domain-Core 모듈은 모든 domain 모듈, application 모듈에서 공통으로 의존하는 도메인 개념을 정의하였습니다.
9.1 DomainId, DomainEntity
package com.ttasjwi.board.system.core.domain.model
abstract class DomainId<T>(
val value: T
) {
abstract override fun equals(other: Any?): Boolean
abstract override fun hashCode(): Int
abstract override fun toString(): String
}
package com.ttasjwi.board.system.core.domain.model
abstract class DomainEntity<ID: DomainId<*>>(
id: ID? = null
) {
var id: ID? = id
private set
fun initId(id: ID) {
if (this.id != null) {
throw IllegalStateException("ID가 이미 초기화된 상황입니다. (this.id = ${this.id}, id = $id)")
}
this.id = id
}
}
식별자가 있는 Domain 개념들을 도메인 엔티티라 하였을 때
이들이 가져야할 식별자에 대한 타입을 정의하기 위해 DomainId, DomainEntity 개념을 정의했습니다.
도메인 엔티티들은 DomainId를 가지며, 이것이 null 아니게 된 순간 초기화됩니다.
9.2 DomainEvent
package com.ttasjwi.board.system.core.domain.event
import java.time.ZonedDateTime
abstract class DomainEvent<T>(
val occurredAt: ZonedDateTime,
val data: T,
)
DomainEvent 는 이벤트를 정의하기 위한 인터페이스입니다.
어떤 사건이 일어났을 때 이 사건을 정의하기 위한 개념으로 발생한 시간 정보를 공통으로 가지고, 부가적인 속성을 가질 수 있게 했습니다.
이후 필요에 따라 이벤트드링 공통적으로 가져야하는 속성이 있다면 추가적으로 정의할 예정입니다.
9.3 도메인 개념(MemberId, Username, Nickname, Email, Role)
package com.ttasjwi.board.system.member.domain.model
class Nickname(
val value: String
) {
companion object {
fun restore(value: String): Nickname {
return Nickname(value)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Nickname) return false
return value == other.value
}
override fun hashCode(): Int {
return value.hashCode()
}
override fun toString(): String {
return "Nickname(value=$value)"
}
}
여러 모듈에서 공통으로 사용할 여지가 있는 사용자 정보 프로퍼티, Username, Nickname, Email, MemberId는 이 모듈에 두었습니다.
그리고 도메인 엔티티의 생성은 생성 그 자체의 이유를 통제할 수 있도록, 정해진 정적 팩터리 메서드를 통해서만 가능하게 할 계획입니다.
예를 들어 restore 메서드는 데이터베이스 등에서 꺼내온 데이터 객체의 문자열값으로부터 각 객체를 복원하는 목적으로 사용하는 메서드입니다.
현재는 생성자가 public 으로 열려있는데, 이렇게 되면 모든 모듈들에서 public 하게 열려있는 생성자를 호출해서 Email 을 바로 생성할 수 있는 문제가 있습니다. 이 부분은 향후 수정할 예정입니다. 다만 지금은 testFixture를 외부 모듈들에서도 다시 정의해서 사용해야하기 때문에 생성자는 public으로 열어뒀습니다.
package com.ttasjwi.board.system.member.domain.model
/**
* 우리 서비스에서 사용되는 역할들
*/
enum class Role {
USER, ADMIN, ROOT, SYSTEM;
companion object {
fun restore(roleName: String): Role {
return Role.valueOf(roleName)
}
}
override fun toString(): String {
return "Role(name=$name)"
}
}
Role 은 enum 으로서, 우리 서비스에서의 사용자의 권한 수준을 정의하였습니다.
USER 는 일반 사용자,
ADMIN 은 중간단계 관리자,
ROOT 는 최고 권한 사용자,
SYSTEM은 우리 애플리케이션을 관리하는 또 다른 역할인데, 특정 사람이 아니라 외부 애플리케이션 등이 우리 애플리케이션을 실행하는 상황에서의 역할을 칭합니다.
restore 메서드는 역할 이름으로부터 Role 을 복원하는 역할을 합니다.
9.4 도메인 서비스
package com.ttasjwi.board.system.member.domain.service
import com.ttasjwi.board.system.member.domain.model.Email
interface EmailCreator {
fun create(value: String): Result<Email>
}
이메일/닉네임/사용자 아이디를 생성하는 기능을 이 모듈에 두었습니다.
사실 이메일, 닉네임, 사용자 아이디를 생성하는 기능은 application-member 모듈에서만 사용합니다. 그래서 이 도메인 서비스 기능은 domain-member 에 둘까 생각도 했었습니다.
하지만 이메일/닉네임/사용자 아이디 개념은 domain-core 모듈에 두고, 이메일/닉네임/사용자 아이디 생성을 domain-member 모듈로 두면 도메인 객체 내부에만 internal 키워드로 은닉시켜도 될 기능을 public 으로 열어야 하는 일이 생기기 때문에 domain-core 모듈에 이메일/닉네임/사용자 아이디 생성 도메인 서비스 기능을 두었습니다.
이메일 인스턴스 생성을 하고 싶다면, EmailCreator.create 를 통해 이메일을 생성해야합니다. 그리고 내부 구현 로직은 구현체에 두도록 합니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.service.EmailCreator
@DomainService
internal class EmailCreatorImpl : EmailCreator {
override fun create(value: String): Result<Email> {
TODO("Not yet implemented")
}
}
@DomainService 를 통해 스프링 빈으로 등록시켜두고, 구현은 작성하지 않았습니다.
9.5 domain-core 모듈 테스트
Domain-Core 모듈에는 간단한 도메인모델 기능에 대한 픽스쳐 및 테스트를 모아뒀습니다.
package com.ttasjwi.board.system.member.domain.model.fixture
import com.ttasjwi.board.system.member.domain.model.Email
fun emailFixture(
value: String = "test@gmail.com"
): Email {
return Email(value)
}
테스트 픽스쳐입니다.
테스트 목적으로 Email 인스턴스를 생성할 때는 emailFixture를 통해 생성해야만 하도록 할 예정입니다.
@DisplayName("Email 테스트")
class EmailTest {
@Test
@DisplayName("restore: 이메일 값으로부터 Email 인스턴스를 복원한다.")
fun testRestore() {
val value = "hello@gmail.com"
val email = Email.restore(value)
assertThat(email.value).isEqualTo(value)
}
@Nested
@DisplayName("equals & hashCode")
inner class EqualsAndHashCode {
@Test
@DisplayName("참조가 같으면 동등하다.")
fun sameReference() {
val email = emailFixture("test@gmail.com")
assertThat(email).isEqualTo(email)
assertThat(email.hashCode()).isEqualTo(email.hashCode())
}
이렇게 Email 관련된 테스트를 작성할 때는 restore 와 같은 특수목적 생성 메서드를 제외하고는 emailFixture를 통해서 인스턴스를 생성하게 하여 테스트합니다.
10. Domain-Member 모듈
dependencies {
implementation(project(":board-system-domain:domain-core"))
}
모든 도메인모듈/애플리케이션에서 공통으로 사용할 수 있는는 회원 개념/기능은 domain-core 모듈에 두고,
회원 관련된 애플리케이션 모듈/도메인 모듈들에서 사용할 수 있는 회원 도메인 기능은 domain-member 모듈에 둘 것입니다.
의존성은 domain-core만을 모듈의 의존성으로 설정합니다.
여러가지 회원 관련 이벤트, 도메인 모델, 도메인 서비스/구현체를 정의해두고 골격만 만들어뒀습니다.
10.1 회원 이벤트
package com.ttasjwi.board.system.member.domain.event
import com.ttasjwi.board.system.core.domain.event.DomainEvent
import java.time.ZonedDateTime
class EmailVerificationStartedEvent(
email: String,
code: String,
codeCreatedAt: ZonedDateTime,
codeExpiresAt: ZonedDateTime,
) : DomainEvent<EmailVerificationStartedEvent.Data>(
occurredAt = codeCreatedAt,
data = Data(email, code, codeCreatedAt, codeExpiresAt)
) {
class Data(
val email: String,
val code: String,
val codeCreatedAt: ZonedDateTime,
val codeExpiresAt: ZonedDateTime,
)
}
DomainEvent 를 상속하여, 이벤트를 정의해뒀습니다.
언제 발생했는지, 그리고 부가적인 정보가 필요하다면 해당 정보도 포함해서 이벤트를 정의합니다.
10.2 회원 도메인 모델
class Member
internal constructor(
id: MemberId? = null,
email: Email,
password: EncodedPassword,
username: Username,
nickname: Nickname,
role: Role,
val registeredAt: ZonedDateTime,
) : DomainEntity<MemberId>(id) {
var email: Email = email
private set
var password: EncodedPassword = password
private set
var username: Username = username
private set
var nickname: Nickname = nickname
private set
var role: Role = role
private set
companion object {
fun restore(
id: Long,
email: String,
각 도메인 객체들은 도메인 개념이 가져야할 속성을 정의해두고, 복원 메서드로 restore 정도만 정의해뒀습니다.
이 때 도메인 기능은 도메인 객체 내부에 public 하게 정의해두지 않습니다.
10.3 회원 도메인 서비스
package com.ttasjwi.board.system.member.domain.service
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.Member
import com.ttasjwi.board.system.member.domain.model.Nickname
import com.ttasjwi.board.system.member.domain.model.RawPassword
import com.ttasjwi.board.system.member.domain.model.Username
import java.time.ZonedDateTime
interface MemberCreator {
fun create(
email: Email,
emailVerified: Boolean,
password: RawPassword,
username: Username,
nickname: Nickname,
currentTime: ZonedDateTime
): Member
}
회원과 관련된 도메인 서비스 인터페이스 및 구현체 골격 들만 정의해뒀습니다.
예를 들어 회원 생성과 관련된 기능은 MemberCreator 를 통해서만 진행할 수 있도록 하고
@DomainService
class MemberCreatorImpl : MemberCreator {
override fun create(
email: Email,
emailVerified: Boolean,
password: RawPassword,
username: Username,
nickname: Nickname,
currentTime: ZonedDateTime
): Member {
TODO("Not yet implemented")
}
}
구현은 구현체 내부에서 하도록 합니다.
이렇게 하면 외부 모듈 입장에서는 회원 생성은 MemberCreator를 통해서만 할 수 있고 내부적으로 어떻게 생성되는 지 관심을 끌 수 있습니다. 어떻게 기능이 구현되는 지는 이후 글에서 다루도록 하겠습니다.
package com.ttasjwi.board.system.member.domain.service
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.Member
import com.ttasjwi.board.system.member.domain.model.MemberId
import com.ttasjwi.board.system.member.domain.model.Nickname
import com.ttasjwi.board.system.member.domain.model.Username
interface MemberFinder {
fun findByIdOrNull(id: MemberId): Member?
fun existsById(id: MemberId): Boolean
fun findByEmailOrNull(email: Email): Member?
fun existsByEmail(email: Email): Boolean
fun findByUsernameOrNull(username: Username): Member?
fun existsByUsername(username: Username): Boolean
fun findByNicknameOrNull(nickname: Nickname): Member?
fun existsByNickname(nickname: Nickname): Boolean
}
그런데 경우에 따라 도메인 모듈 내부에서 기능 구현을 할 수 없는 도메인 서비스도 있습니다.
예를 들어 데이터베이스 등을 통해 찾아오는 역할인 MemberFinder 는 domain 모듈 내부에서 구현하기 힘듭니다.
데이터베이스 관련 설정이 필요하기 때문이죠.
package com.ttasjwi.board.system.member.domain.external.db
import com.ttasjwi.board.system.core.annotation.component.AppComponent
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.Member
import com.ttasjwi.board.system.member.domain.model.MemberId
import com.ttasjwi.board.system.member.domain.model.Nickname
import com.ttasjwi.board.system.member.domain.model.Username
import com.ttasjwi.board.system.member.domain.service.MemberAppender
import com.ttasjwi.board.system.member.domain.service.MemberFinder
@AppComponent
internal class MemberStorage : MemberAppender, MemberFinder {
override fun save(member: Member): Member {
TODO("Not yet implemented")
}
override fun findByIdOrNull(id: MemberId): Member? {
TODO("Not yet implemented")
}
// 생략
}
이런 상황일 경우 실제 도메인 서비스 구현체를 외부 모듈(저의 경우 external-db)에 두어 기능을 구현하게 하면 됩니다.
container 에 external-db 모듈 의존성을 등록해두면 실제 통합적으로 설정을 끌어와 실행될 때 구현체가 잘 빈으로 등록될 수 있을겁니다.
10.4 테스트 코드 작성, 테스트 픽스쳐
도메인 모델, 도메인 이벤트 객체에 대한 간단한 테스트 픽스쳐/테스트 코드를 작성했습니다.
package com.ttasjwi.board.system.core.time.fixture
import java.time.ZoneId
import java.time.ZonedDateTime
fun timeFixture(
year: Int = 1980,
month: Int = 1,
dayOfMonth: Int = 1,
hour: Int = 0,
minute: Int = 0,
second: Int = 0,
nanoOfSecond: Int = 0,
zone: ZoneId = ZoneId.of("Asia/Seoul")
): ZonedDateTime {
return ZonedDateTime.of(
year,
month,
dayOfMonth,
hour,
minute,
second,
nanoOfSecond,
zone
)
}
테스트 작성을 하다보니 ZonedDateTime 을 통해 시간 객체 생성을 하는 것이 매번 상당히 귀찮은 감이 있는데, timeFixture 클래스를 추가적으로 만들어뒀습니다.
package com.ttasjwi.board.system.member.domain.model.fixture
import com.ttasjwi.board.system.core.time.fixture.timeFixture
import com.ttasjwi.board.system.member.domain.model.Member
import com.ttasjwi.board.system.member.domain.model.Role
import java.time.ZonedDateTime
fun memberFixtureRegistered(
id: Long = 1L,
email: String = "test@gmail.com",
password: String = "1111",
username: String = "test",
nickname: String = "테스트유저",
role: Role = Role.USER,
registeredAt: ZonedDateTime = timeFixture()
): Member {
return Member(
id = memberIdFixture(id),
email = emailFixture(email),
password = encodedPasswordFixture(password),
username = usernameFixture(username),
nickname = nicknameFixture(nickname),
role = role,
registeredAt = registeredAt,
)
}
회원에 대한 픽스쳐 작성 시, emailFixture, usernameFixture 등 domain-core 모듈에 위치한 도메인 객체에 대한 테스트 의존성도 필요한 문제가 있습니다.
이전 글들에서부터 설명한 테스트 픽스쳐의 중복 문제가 이번 글에서도 발생하는데요.
일단은 당장의 테스트가 잘 돌아갈 수 있도록, domain-member 모듈 내에 동일한 테스트 픽스쳐 코드를 붙여넣기 해서 테스트 코드를 다시 사용할 수 있게 해서 코드를 작성했습니다.
@DisplayName("EmailVerification 테스트")
class EmailVerificationTest {
@Nested
@DisplayName("restore: 값으로부터 이메일 인증 인스턴스를 복원한다.")
inner class Restore {
@Test
@DisplayName("전달된 값으로 인스턴스를 성공적으로 복원해야한다.")
fun test() {
val email = "nyaru@gmail.com"
val code = "ads7fa778"
val codeCreatedAt = timeFixture(minute = 3)
val codeExpiresAt = timeFixture(minute = 8)
val verifiedAt = timeFixture(minute = 5)
val verificationExpiresAt = timeFixture(minute = 35)
val emailVerification = EmailVerification.restore(
email = email,
code = code,
codeCreatedAt = codeCreatedAt,
codeExpiresAt = codeExpiresAt,
verifiedAt = verifiedAt,
verificationExpiresAt = verificationExpiresAt
)
assertThat(emailVerification.email).isEqualTo(emailFixture(email))
assertThat(emailVerification.code).isEqualTo(code)
그리고 도메인 테스트 코드 작성 시, 테스트를 위한 객체들은 testFixture 들을 적극적으로 활용하여 코드를 작성했습니다.
테스트를 위한 도메인 객체 생성은 Fixture 클래스쪽에 전부 집중시키는 방식을 채용함으로서 복잡한 테스트 인스턴스 생성을 간소화시키고 재사용성을 높일 수 있습니다.
다만 지금은 동일한 테스트 픽스쳐 코드를 여러 모듈에 복사/붙여넣기 방식으로 두고 있는데 이건 다음 글에서 개선해볼 예정입니다.
여기까지 모듈들을 쪼개가면서 회원 가입 기능을 위한 인터페이스들을 작성해봤습니다.
여기까지의 작업을 하는데 작성한 커밋의 수가 19개 정도 되고 추가/변경된 파일은 137개가 되며, 변경 라인수가 3200줄 정도 되다보니, 모든 코드를 이 글에서 다루는 것은 어려웠습니다. 양해부탁드립니다.
실제로 배포 결과물에 대해 새로 정의한 api를 호출해봤는데, 미구현 예외가 발생하고 메시지 처리가 잘 되는 것을 볼 수 있습니다. 애플리케이션 실행은 잘 됐고, 이제 구현만 해주면 될 것 같아요.
이번 글에서는 회원 가입 관련 기능을 구현하기 앞서 필요한 인터페이스를 작성했습니다.
이어지는 글들에서는 지금 글에서 다룬 테스트 픽스쳐 문제점을 해결하고, 회원 가입 기능을 실제로 구현해보도록 하겠습니다.
리포지토리: https://github.com/ttasjwi/board-system/
PR: https://github.com/ttasjwi/board-system/pull/27
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 12. 이메일/사용자 아이디/닉네임 사용가능 여부 확인 API 구현 (0) | 2024.10.31 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 11. 멀티모듈과 테스트 픽스쳐 중복문제 (0) | 2024.10.29 |
[토이프로젝트] 게시판 시스템(board-system) 9. API 예외 메시지 처리 (0) | 2024.10.21 |
[토이프로젝트] 게시판 시스템(board-system) 8. 메시지,국제화 / API 응답 규격 (0) | 2024.10.16 |
[토이프로젝트] 게시판 시스템(board-system) 7. 로깅 모듈 (0) | 2024.10.03 |