본문 바로가기

MSA

Kotlin으로 직접 구현하며 배우는 MSA - 2. 회원, 상품 도메인 만들기

✨ 공부하며 성장하는 기록 공간입니다.


아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 하나씩 알아가는 과정에서 큰 재미를 느끼고 있습니다.

이 블로그는 제가 공부한 내용을 정리하고, 직접 구현하며 겪은 고민과 시행착오를 기록하기 위한 공간입니다.

틀리거나 부족한 내용이 있을 수 있습니다. 잘못된 내용이나 더 나은 방향이 있다면 언제든지 편하게 지적해 주세요 😁


해당 포스팅 시리즈에서는 Kotlin/Spring 기반으로 MSA를 이해해보는 과정을 다룰 예정입니다.

단순히 기술을 사용하는 방법만 정리하기보다는, 초기 프로젝트 구축부터 시작해 특정 문제가 왜 발생했는지, 그 문제를 해결하기 위해 어떤 기술과 구조가 등장했는지, 그리고 실제 개발 과정에서는 어떻게 적용할 수 있는지를 함께 공부해보려고 합니다. 가능한 한 복잡한 개념도 쉽게 풀어내고, 제가 직접 이해한 방식으로 정리해보겠습니다.

 

이전 글에서는 Kotlin + Spring Boot + Gradle Kotlin DSL 기반으로 초기 프로젝트를 구성했다.

현재 프로젝트는 다음 두 개의 모듈로 이루어져 있다.

common
commerce-monolith

common은 공통 응답 모델을 담는 라이브러리 모듈이고, 실제로 실행되는 애플리케이션은 commerce-monolith 하나뿐이다.

이번 글에서는 이 commerce-monolith 안에 첫 번째 도메인인 회원 도메인과 상품 도메인을 추가해보려고 한다.

 

이번 글에서 할 일

이번 글의 목표는 간단하다.

모놀리식 애플리케이션 내부에 회원 도메인과 상품 도메인을 추가한다.

구현할 기능은 다음과 같다.

# 회원 도메인
회원 가입
회원 조회
Mock 로그인

# 상품 도메인
상품 등록
상품 전체 검색
상품 단일 검색

회원 도메인

API 기준으로 보면 다음 세 가지다.

POST /api/v1/members
GET /api/v1/members/{memberId}
POST /api/v1/members/login

여기서 중요한 점은 아직 인증 시스템을 제대로 만들지 않는다는 것이다.

이번 단계의 로그인은 실제 JWT 인증이 아니다.

단순히 이후 주문 기능에서 “로그인한 회원이 주문한다”는 흐름을 만들기 위한 Mock 로그인이다.

 

현재 구조

현재 프로젝트 구조는 대략 다음과 같다.

mini-commerce
 ├── common
 │   └── 공통 응답 / 에러 모델
 └── commerce-monolith
     └── 실제 실행 애플리케이션

이번 작업 후에는 commerce-monolith 내부에 member 도메인이 추가된다.

commerce-monolith
 └── src/main/kotlin/com/minicommerce/commerce
     ├── health
     └── member
         ├── controller
         ├── dto
         ├── application
         ├── domain
         └── infrastructure

구조를 이렇게 나눈 이유는 나중에 서비스를 분리하기 위해서다.

DDD를 따라서 더 효율적인 구조로 만들기 위해 따로 분리한다.

 

회원이라는 기능을 하나의 도메인으로 보고, 그 안에서도 역할에 따라 코드를 나누기 위해서다. 나중에 프로젝트 규모가 커지거나 서비스를 분리해야 할 때, 이미 도메인 단위로 코드가 정리되어 있으면 변경 범위를 파악하기 쉽고 구조를 확장하기도 수월하다.

 

DDD란

더보기

DDD는 Domain-Driven Design의 약자로, 한국어로는 보통 도메인 주도 설계라고 부른다.

여기서 도메인이란 단순히 기술적인 영역이 아니라, 우리가 해결하려는 비즈니스 문제의 영역을 의미한다. 쇼핑몰 프로젝트라면 회원, 상품, 주문, 결제, 배송 같은 개념들이 각각 중요한 도메인이 될 수 있다.

예를 들어 회원 도메인에서는 다음과 같은 규칙이 중요할 수 있다.

- 회원은 이메일을 가지고 가입한다.
- 같은 이메일로 중복 가입할 수 없다.
- 회원은 이름, 이메일, 상태값을 가진다.
- 탈퇴한 회원은 로그인할 수 없다.

DDD에서는 이런 비즈니스 규칙을 단순히 Controller나 Service에 흩뿌려 놓는 것이 아니라, 최대한 도메인 중심으로 표현하려고 한다.

즉, 핵심은 “DB 테이블 기준으로 코드를 나누는 것”이 아니라 “비즈니스 개념 기준으로 코드를 나누는 것”이다.

이번 프로젝트에서는 아직 복잡한 DDD 패턴을 모두 적용하지는 않는다. 처음부터 Aggregate, Value Object, Domain Event 같은 개념을 무리하게 적용하기보다는, 우선 도메인 중심으로 패키지를 나누고 각 계층의 책임을 분리하는 것부터 시작한다.


패키지 구조

회원 도메인은 다음 패키지 아래에 만들었다.

com.minicommerce.commerce.member

그리고 내부는 역할별로 나누었다.

member
 ├── controller
 ├── dto
 ├── application
 ├── domain
 └── infrastructure

각 패키지의 역할은 다음과 같다.

패키지 명 패키지 역할
controller HTTP 요청을 받는 계층
dto 요청/응답 객체
application 회원 가입, 조회, 로그인 흐름 처리
domain Member 엔티티와 도메인 규칙
infrastructure JPA Repository 등 외부 기술 연동

 

Member Entity 만들기

@Entity
@Table(name = "members")
class Member(
    @Column(nullable = false, unique = true, length = 255)
    val email: String,

    @Column(nullable = false, length = 100)
    val name: String,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @Column(nullable = false)
    val createdAt: LocalDateTime = LocalDateTime.now()
}

여기에서 email을 중복하지 않도록 작성했다.

 

회원 가입 API

회원 가입 요청은 다음과 같다.

POST /api/v1/members

요청 Body는 다음처럼 보낸다.

{
  "email": "hiro@example.com",
  "name": "hiro"
}

성공하면 다음과 같은 응답을 받는다.

회원가입 성공

회원 가입 과정은 단순하다.

요청 검증
→ 이메일 중복 확인
→ Member 생성
→ DB 저장
→ 응답 반환

코드 흐름으로 보면 Controller는 요청을 받고, 실제 회원 생성 로직은 Application Service에서 처리한다.

Controller에 비즈니스 로직을 넣지 않는 이유는 역할을 분리하기 위해서다.

컨트롤러는 HTTP 요청과 응답에 집중하고, 회원 가입이라는 업무 흐름은 서비스 계층에서 처리한다.

 

이메일 중복 응답 예시

postman signup 실패(이메일 중복)

 

회원 조회 API

회원 조회 API는 다음과 같다.

GET /api/v1/members/{memberId}

 

성공 응답은 다음과 같다.

postman 회원 조회 성공

존재하지 않는 회원을 조회하면 에러를 반환한다.

존재하지 않는 회원 조회

하지만 최소한 클라이언트가 어떤 문제가 발생했는지 알 수 있도록 codemessage를 내려주도록 했다.

 

 

로그인 API

이번 글에서 만든 로그인은 실제 인증이 아니다.

API는 다음과 같다.

POST /api/v1/members/login

요청 Body는 다음과 같다.

{
  "email": "hiro@example.com"
}

응답은 다음처럼 단순한 Mock Token을 반환한다.

postman login 성공

존재하지 않은 멤버로 조회시

postman login 실패(존재하지 않는 User)

여기서 mockAccessToken은 실제 보안 토큰이 아니다.

이 값을 가지고 인증을 처리하지도 않는다.

아직 인증 시스템을 만들지 않았지만, 주문 흐름을 테스트하려면 “어떤 회원이 주문했는지”를 표현할 방법이 필요하다.

그래서 지금은 Mock 로그인으로 최소한의 흐름만 만들어두었다.

 

전체 MemberService

@Service
class MemberService(
    private val memberRepository: MemberRepository,
) {

    @Transactional
    fun signup(request: SignupMemberRequest): MemberResponse {
        if (memberRepository.existsByEmail(request.email)) {
            throw DuplicateMemberEmailException()
        }

        val member = memberRepository.save(
            Member(
                email = request.email,
                name = request.name,
            ),
        )

        return MemberResponse.from(member)
    }

    @Transactional(readOnly = true)
    fun findById(memberId: Long): MemberResponse {
        val member = memberRepository.findById(memberId)
            .orElseThrow { MemberNotFoundException() }

        return MemberResponse.from(member)
    }

    @Transactional(readOnly = true)
    fun mockLogin(email: String): MockLoginResponse {
        val member = memberRepository.findByEmail(email)
            ?: throw MemberNotFoundException()

        return MockLoginResponse(
            memberId = member.id,
            mockAccessToken = "mock-token-${member.id}",
        )
    }
}

 


MemberController

MemberService를 호출하는 Controller layer로써, 비즈니스 로직을 수행하지 않는다.

@RestController
@RequestMapping("/api/v1/members")
class MemberController(
    private val memberService: MemberService,
) {

    @PostMapping
    fun signup(
        @Valid @RequestBody request: SignupMemberRequest,
    ): ApiResponse<MemberResponse> = ApiResponse.success(memberService.signup(request))

    @GetMapping("/{memberId}")
    fun findById(
        @PathVariable memberId: Long,
    ): ApiResponse<MemberResponse> = ApiResponse.success(memberService.findById(memberId))

    @PostMapping("/login")
    fun login(
        @Valid @RequestBody request: LoginMemberRequest,
    ): ApiResponse<MockLoginResponse> = ApiResponse.success(memberService.mockLogin(request.email))
}

에러 처리

Spring의 가장 대표적인 기능 중 하나는 AOP이다. AOP를 구현한 것 중 하나인 GlobalExceptionHandler를 적용하려고 한다.(개념 설명은 건너뛴다. 또한, 유지보수의 향상을 위해 도입한다.)

 

이번 단계에서 다룬 대표적인 에러는 다음과 같다.

중복 이메일
존재하지 않는 회원
잘못된 요청 값

예를 들어 이미 가입된 이메일로 다시 가입하려고 하면 에러를 반환한다.

{
  "success": false,
  "error": {
    "code": "DUPLICATE_MEMBER_EMAIL",
    "message": "이미 사용 중인 이메일입니다."
  }
}

따라서 MemberException을 정의하였다.

MemberException

sealed class MemberException(
    val errorCode: MemberErrorCode,
) : RuntimeException(errorCode.message)

class DuplicateMemberEmailException : MemberException(MemberErrorCode.MEMBER_EMAIL_DUPLICATED)

class MemberNotFoundException : MemberException(MemberErrorCode.MEMBER_NOT_FOUND)

MemberException을 상속받는 클래스를 정의하였따.

  • DuplicateMemberEmailException: 중복 이메일 등록할 때 발생하는 Exception
  • MemberNotFoundException: 로그인할 때 없는 멤버 조회하는 Exception

GlobalExceptionHandler

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(DuplicateMemberEmailException::class)
    fun handleDuplicateMemberEmail(exception: DuplicateMemberEmailException): ResponseEntity<ApiResponse<Nothing>> =
        handleMemberException(exception, HttpStatus.CONFLICT)

    @ExceptionHandler(MemberNotFoundException::class)
    fun handleMemberNotFound(exception: MemberNotFoundException): ResponseEntity<ApiResponse<Nothing>> =
        handleMemberException(exception, HttpStatus.NOT_FOUND)

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleInvalidRequest(): ResponseEntity<ApiResponse<Nothing>> = ResponseEntity
        .status(HttpStatus.BAD_REQUEST)
        .body(ApiResponse.failure("INVALID_REQUEST", "Request validation failed."))

    private fun handleMemberException(
        exception: MemberException,
        status: HttpStatus,
    ): ResponseEntity<ApiResponse<Nothing>> = ResponseEntity
        .status(status)
        .body(ApiResponse.failure(exception.errorCode.code, exception.errorCode.message))
}

각 Exception이 발생했을 때 catch-up해서 Exception을 넘기도록 @RestControllerAdvice와 @ExceptionHandler를 정의하였따.

 


마무리

 

이번에는 아주 단순한 회원 기능을 만들었다. 기능만 보면 특별할 것이 없다.

 

하지만 이 시리즈에서 중요한 것은 기능 자체보다 구조의 변화다.

 

현재는 모든 기능이 하나의 애플리케이션 안에 들어간다. 그래서 회원 가입을 만들고, 바로 같은 애플리케이션 안에서 회원을 조회할 수 있다. 아직 네트워크 호출도 없고, 서비스 간 장애도 없다.

 

이것이 모놀리식의 장점이다. 구현이 단순하고, 디버깅이 쉽다.

 

하지만 앞으로 상품, 주문, 결제, 알림 기능이 추가되면 이야기가 달라질 수 있다.

도메인이 많아질수록 코드가 커지고, 도메인 간 의존성도 생긴다.

 

그때부터 “이 기능을 분리하는 게 좋을까?”라는 질문이 자연스럽게 생긴다.

이슈가 생길 때까지 모놀리식으로 진행해보려고 한다.

 

구현한 기능은 다음과 같다.

회원 가입
회원 조회
Mock 로그인

 


상품 도메인

사실 상품 도메인을 회원 도메인과 분리해서 설명하기에는 중복되는 부분이 많이 존재한다.

따라서, Controller와 Service, 응답들을 확인하고 넘어가려고 한다.

 

ProductService

@Service
class ProductService(
    private val productRepository: ProductRepository,
) {

    @Transactional
    fun register(request: RegisterProductRequest): ProductResponse {
        val product = productRepository.save(
            Product(
                name = request.name,
                price = request.price,
                stockQuantity = request.stockQuantity,
            ),
        )

        return ProductResponse.from(product)
    }

    @Transactional(readOnly = true)
    fun findAll(): List<ProductResponse> = productRepository.findAllByOrderByIdAsc()
        .map(ProductResponse::from)

    @Transactional(readOnly = true)
    fun findById(productId: Long): ProductResponse {
        val product = productRepository.findById(productId)
            .orElseThrow { ProductNotFoundException() }

        return ProductResponse.from(product)
    }
}

ProductController

@RestController
@RequestMapping("/api/v1/products")
class ProductController(
    private val productService: ProductService,
) {

    @PostMapping
    fun register(
        @Valid @RequestBody request: RegisterProductRequest,
    ): ApiResponse<ProductResponse> = ApiResponse.success(productService.register(request))

    @GetMapping
    fun findAll(): ApiResponse<List<ProductResponse>> = ApiResponse.success(productService.findAll())

    @GetMapping("/{productId}")
    fun findById(
        @PathVariable productId: Long,
    ): ApiResponse<ProductResponse> = ApiResponse.success(productService.findById(productId))
}

 

 

다음 글:
Kotlin으로 직접 구현하며 배우는 MSA - 3. 주문 도메인 만들기

 

코드 참조