본문 바로가기
Kotlin Spring/Kotlin Spring 강의 내용

6) 개발 architecture (1) 레이어드 아키텍처(Layered Architecture)

by Bill Lab 2025. 8. 31.
728x90

1. 레이어드 아키텍처 개념

      : 레이어드 아키텍처는 전통적인 애플리케이션 구조로, 관심사의 분리

       (Separation of Concerns)에 기반해 계층을 나누는 패턴.
     

2. 계층(Layer) 구조   

 

    1) Presentation Layer (Controller, UI)

         - 사용자의 요청을 받아서 응용 계층(비지니스 계층)에 전달

         - DTO 변환, HTTP 응답 처리

 

     2) Application Layer (Application Service) - (선택)

         - 비즈니스 흐름(Use Case) 조합

         - 도메인 로직을 직접 가지지 않고 도메인 객체를 조립 및 조율

   

     3) Domain Layer (Entity, Domain Service, repositorty 추상체)

         - 순수한 비즈니스 로직(domain service)

         - 외부 프레임워크 의존

         - 핵심 규칙과 불변식을 책임짐

 

      4) Infrastructure Layer (Repository(구현체), 외부 API 등 연결)

          - 기술적 세부사항 (DB, Kafka, Redis 등)

          - 인터페이스 기반으로 응용/도메인 계층에서 사용

 

com.example
 ├─ user
 │   ├─ domain
 │   │    └─ User.kt
 │   ├─ infrastructure
 │   │    └─ UserJpaRepository.kt
 │   ├─ application
 │   │    └─ UserService.kt
 │   └─ presentation
 │        └─ UserController.kt
 │
 ├─ coupon
 │   ├─ domain
 │   │    └─ Coupon.kt
 │   └─ application
 │        └─ CouponService.kt
 │
 └─ common
      └─ exception ...

 

3. 예제 코드

     : 레이어드 아키텍처 기반으로 “회원 가입 & 쿠폰 발급”로직을 가볍게 구현단하면?

 

     [요구사항]

     - 사용자가 회원 가입을 하면, 환영 쿠폰을 1장 발급한다.

     - 회원 가입 시 이메일 중복 검증이 필요하다.

     - 가입 완료 후 이벤트 로그를 남긴다.

 

1) Controller

package com.example.user.presentation

import com.example.user.application.UserService
import org.springframework.web.bind.annotation.*

data class UserRegisterRequest(val email: String, val name: String)
data class UserResponse(val id: Long?, val email: String, val name: String)

@RestController
@RequestMapping("/users")
class UserController(
    private val userService: UserService
) {
    @PostMapping("/register")
    fun register(@RequestBody request: UserRegisterRequest): UserResponse {
        val user = userService.registerUser(request.email, request.name)
        return UserResponse(user.id, user.email, user.name)
    }
}

 

2) Application Service

package com.example.user.application

import com.example.user.domain.User
import com.example.user.infrastructure.UserJpaRepository
import com.example.coupon.domain.Coupon
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

//user service
@Service
class UserService(
    private val userRepository: UserJpaRepository,
    private val couponService: CouponService
) {
    @Transactional
    fun registerUser(email: String, name: String): User {
        userRepository.findByEmail(email)?.let {
            throw IllegalArgumentException("이미 존재하는 이메일입니다.")
        }

        val user = User(email = email, name = name).apply { validateEmail() }
        val savedUser = userRepository.save(UserEntity.fromDomain(user)).toDomain()

        couponService.issueWelcomeCoupon(savedUser.id!!)
        return savedUser
    }
}


//coupon service
@Service
class CouponService {
    fun issueWelcomeCoupon(userId: Long): Coupon {
        CouponRepository.save()
        return Coupon(userId = userId, code = "WELCOME", discountRate = 10)
        
    }
}

 

3) Domain

//user domain
package com.example.user.domain

data class User(
    val id: Long? = null,
    val email: String,
    val name: String
) {
    fun validateEmail() {
        require(email.contains("@")) { "유효하지 않은 이메일 형식입니다." }
    }
}

//coupon domain
package com.example.coupon.domain

data class Coupon(
    val id: Long? = null,
    val userId: Long,
    val code: String,
    val discountRate: Int
)

 

4) Infrastructure

package com.example.user.infrastructure

import com.example.user.domain.User
import org.springframework.data.jpa.repository.JpaRepository

interface UserJpaRepository : JpaRepository<UserEntity, Long> {
    fun findByEmail(email: String): UserEntity?
}

@Entity
@Table(name = "users")
data class UserEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val email: String,
    val name: String
) {
    fun toDomain() = User(id, email, name)

    companion object {
        fun fromDomain(user: User) = UserEntity(user.id, user.email, user.name)
    }
}

 

4. 주요 단점

"정통적인 레이어드 사용법이며, 실무에 바로 사용하기에는 여러 가지 문제점을 안고 있는것이 보입니다."
"도메인 순수성 저하"
 - Application Layer에서 유스케이스 흐름 + 외부 의존(DB, API, 메시지 등)을 모두 다룸
 - Domain Service에 외부 의존이 들어가거나 Application Service가 비대해질 수 있음
 - 테스트할 때 단위 테스트와 통합 테스트 경계가 모호

"유스케이스 흐름이 Layered 구조에 제한"
 - 여러 Layer를 거치면서 단일 유스케이스의 흐름이 분산됨
 - Application Service에서 여러 도메인 호출 → 트랜잭션 관리, 예외 처리, 외부 호출이 섞임 → Fat Service 문제 발생

"외부 시스템 교체가 어려움"
 - Infrastructure Layer(DB, Kafka, REST API 등)가 Application Layer/Domain Layer와 강하게 결합될 수 있음
 - DB 교체, 메시징 시스템 변경 시 Domain/Service 코드 일부 수정 필요 → 유연성 낮음

"DI와 의존성 관리 문제"
 - Layered 구조에서 Application Service → Application Service 호출 시 순환 참조 발생 가능
 - Domain → Application 호출을 막지 않으면 의존성 뒤엉킴
 - 순수 규칙과 유스케이스 흐름이 섞이면 DI 구조가 복잡해짐

"규모가 커지면 서비스가 항상 비대해짐"
 - 서비스 단위로 역할이 혼합되면 Application Service가 Fat Service가 됨
 - Domain Service는 역할이 제한적이지만, Application Service에 유스케이스 흐름이 모두 몰리면 유지보수 어려움
728x90