모놀리스 아키텍쳐의 장점과 명확한 한계점
많은 개발 팀들의 MSA의 ‘장점’만 보고 섣불리 도입했다가, 분산 시스템의 복잡성이라는 벽에 부딪힌다. MSA는 은총알(Silver Bullet)이 아니고, 그에 상응하는 비용(복잡성, 운영 오버헤드)를 요구한다.
가장 좋은 아키텍쳐의 학습법은 모놀리스 아키텍쳐의 한계를 직접 체감하고 이를 해결하기 위해 명한한 이유를 가지고 MSA로 진화하는 것이다. 이것이 바로 모놀리스 퍼스트(Monolith First) 전략이다.
모놀리스 아키텍쳐의 장점(Why we start here)
모놀리스는 하나의 큰 코드베이스와 하나의 결과물(app.jar)로 모든 비즈니스 로직을 처리하는 구조다. 특히 프로젝트 초기에 장점들을 제공한다.
1. 개발 단순성(Simplicity)
- 단일 코드 베이스: IDE 하나에서 모든 코드를 관리할 수 있다.
OrderService가MemberService를 호출하는 것은 단순한 메서드 호출일 뿐, 네트워크 통신을 고민할 필요가 없다. - 쉬운 리팩토링:
Member클래스의 필드명을 변경하면, 이 클래스를 참조하는Order코드까지 IDE가 한번에 찾아 안전하게 수정한다. - 로컬 테스트:
app.jar파일 하나와 DB 하나만 실행하면 전체 시스템이 동작하므로, 로컬 환경에서 테스트하기가 매우 쉽다.
2. 배포 용이성
- 빌드 결과물(jar or war)가 단 하나다. 이 파일만 하나만 서버에 복사하고 실행하면 배포가 끝난다. CI/CD 파이프라인이 극도로 단순해진다.
3. 성능
- 모든 로직이 같은 프로세스(JVM) 내부에서 실행된다. MSA처럼 서비스 간의 HTTP/gRPC 네트워크 호출에서 발생하는 지연시간(Latency)가 전혀 없다.
4. 데이터 일관성
- 단일 데이터베이스를 사용하므로 ACID 트랜잭션을 활용하기 쉽다. 주문 생성시 회원 등급을 업데이트하고 사움 재고를 차감하는 로직을 하나의 DB 트랜잭션으로 묶어 강력한 데이터 일관성을 확보할 수 있다.
모놀리스 아키텍쳐의 한계
이커머스 비즈니스가 성공해서 회원 100만명, 일일 주문 100만건 수준으로 성장했다고 가정하면 이제 모놀리스의 장점은 모두 한계점으로 변한다.
1. 확장성의 한계(Scalability)
- 블랙 프라이데이 세일로 인해 주문 기능에만 트래픽이 몰려도 우리는 주문 기능만 독립적으로 확장(Scale-out)할 수 없다.
- CPU 집약적인 상품추천 로직과 메모리 집약적인 회원 캐시로직이 섞인 모놀리스 전체를 통째로 복제해서 배포해야한다. 이는 자원 낭비가 심하다.
2. 유지보수성의 저하(Maintainability)
- 코드 베이스가 너무 커지고 코드 간의 의존성을 파악하기 불가능해진다.
- 간단한 버그 수정에도 시스템의 어느 부분이 터질지 몰라(Side Effect)가 커진다.
- 빌드와 테스트 시간이 수십 분 이상 걸리게 되어 개발 속도가 급격히 저하된다.
3. 기술 스택의 종속(Technology Lock-in)
- 모든 기능이 Kotlin/Spring/JPA 라는 하나의 기술 스택에 묶인다.
- 상품 추천 기능만 파이썬과 머신러닝 라이브러리를 도입하고싶어도 전체 모놀리스 구조를 바꾸지 않으면 불가능하다.
4. 배포의 위험성(Deployment Risk)
- 중요도 낮은 상품 리뷰 기능의 사소한 버그가 전체 JVM 프로세스를 죽여서 시스템 전체를 마비시킬 수 있다.
- 배포 위험이 크기 때문에 일주일에 한 번 배포하기도 힘들어질 수도 있다.
5. 팀 조직의 병목(Team Bottleneck)
- 주문, 상품, 회원팀 모두가 단 하나의 코드 베이스에 코드를 커밋 한다.
- 서로의 코드가 충돌하고 하나의 배포 파이프라인을 통과하기 위해 병목현상이 발생하며 팀의 민첩성과 자율성이 사라진다.
먼저 모놀리스의 장점을 통해서 회원 API를 구축해보자. 어떤 한계점이 있는지 직접 경험해봐야겠다.
왜 Ktor가 아닌 Spring MVC를 채택할까?
여기서 진행하는 주력 언어는 코틀린이다. 그렇다면 코틀린 애플리케이션을 만들 때, 코틀린 네이티브 프레임워크인 Ktor가 아닌 Spring Boot의 Spring MVC를 선택한 이유가 뭘까?
- Ktor: 코루틴 기반의 경량화되고 유연한 비동기 프레임워크이다. 매우 훌륭한 선택지 이지만 엔터프라이즈급 생태계가 상대적으로 부족하다.
- Spring MVC: Java 진영에서 20년간 검증된, 엔터프라이즈 아키텍쳐의 사실상 표준(de facto standard)이다.
여기서 Spring MVC를 선택한 이유는 명확하다.
- 검증된 EcoSystem: 여기서의 핵심 주제는 Spring Data JPA, Spring Security, Spring Cloud, 와의 완벽한 통합을 제공하는 것은 Spring Boot가 유일하다. Ktor로 이 모든 것을 조합하는 것은 몇 배의 노력이 든다.
- 모놀리스 퍼스트 전략과의 부합: Spring MVC의 1 req - 1res 기반 블로킹 모델은 모놀리스 환경에서 CURD API를 개발하는 가장 빠르고, 가장 단순하며, 가장 직관적인 방법이다.
- 명확한 학습 경로: 우리는 앞에서 블로킹 모델의 한계를 이미 학습했다. MVC 모델의 블로킹으로 빠르게 서비스를 구축하고 Spring WebFlux와 코루틴 기반의 논블로킹 모델로 마이그레이션 해보자.
프로젝트 설정(builc.gradle.kts)
코틀린 + Spring boot 프로젝트를 위한 build.gradle.kts 핵심 의존성을 넣어보자
plugins {
kotlin("jvm") version "2.3.21"
kotlin("plugin.spring") version "2.3.21"
id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "2.3.21"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
// 1. Spring MVC + 내장 Tomcat (구 spring-boot-starter-web)
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// 2. JSON 처리 (Jackson 3)
implementation("org.springframework.boot:spring-boot-starter-jackson")
// 3. JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// 4. 코틀린 data class 직렬화/역직렬화 — groupId 주의!
implementation("tools.jackson.module:jackson-module-kotlin")
// 5. 리플렉션 (stdlib은 Kotlin 플러그인이 자동 추가하므로 생략)
implementation("org.jetbrains.kotlin:kotlin-reflect")
// 테스트 (Boot 4는 기술별 test starter 사용, starter-test는 전이 포함됨)
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
}
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
tasks.withType<Test> {
useJUnitPlatform()
}
메인 애플리케이션 실행
스프링 부트의 진입점
package com.example.ecommerce
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class EcommerceApplication
fun main(args: Array<String>) {
runApplication<EcommerceApplication>(*args)
}
회원 API
표준적인 Layered Architecture를 사용한다.
- Controller(Web Layer): HTTP 요청을 받고, DTO로 변환하며, Service를 호출한다.
- Service(Business Layer): 비즈니스 로직을 수행한다.
- Repository(Data Layer): DB와 통신하여 데이터를 적재하는 역할
DTO(Data Transfer Objects)
package com.example.ecommerce.domain.user.dto.request
data class JoinUserRequestDto(
val email: String,
val name: String,
val address: String
)
package com.example.ecommerce.domain.user.dto.response
class UserResponseDto(
val id: Long,
val email: String,
val name: String
)
Controller& Service(API & Business Skeletons)
RestController를 사용하여 회원 API의 엔드포인트를 정의하고, @Service로 비즈니스 로직을 위임한다.
package com.example.ecommerce.domain.user.service
@Service
@Transactional(readOnly = true)
class UserService {
@Transactional
fun join(requst: JoinUserRequestDto): UserResponseDto {
return UserResponseDto(id = 1L, email = requst.email, requst.name)
}
fun getUser(userId: Long): UserResponseDto{
return UserResponseDto(id = userId, email = "test.gmail.com", name = "Test User")
}
}
package com.example.ecommerce.domain.user.controller
@RestController
@RequestMapping("/api/v1/members")
class UserController(
// 1. 생성자 주입(DI)
private val userService: UserService
) {
@PostMapping("/join")
fun join(@RequestBody request: JoinUserRequestDto) : UserResponseDto{
// 2. 요청 DTO를 Service로 전달
return userService.join(request)
}
@GetMapping("/{userId}")
fun getUser(@PathVariable userId: Long): UserResponseDto{
return userService.getUser(userId)
}
}
회원 API의 스켈레톤이 완성되었고 이제 메인 Appication이 잘 동작하는 것을 확인했다. 이제 내부 코드를 채우고, Spring Data JPA로 데이터를 적재해볼 차례이다.
JPA와 Kotlin @Entity 설계: “회원 도메인 구현”
API 스켈레톤에 Service 내부 코드를 구현할 차례이다.
- @Entity에는
data class를 사용하지 않는다 (JPA의 프록시 및 Lazy Loading 매커니즘과 충돌) kotlin("plugin.jpa")플러그인을 사용한다.(JPA 스펙이 요구하는 no-arg ㄱ기본 생성자를 자동으로 추가해 준다.)@MappedSuperclass등을 활용하여 공통 속성을 분리한다.
BaseEntity
모든 엔티티는 createdAt(생성일시), updatedAt(수정일시)를 가져야한다. 이를 @MappedSuperclass로 분리하여 코드 중복을 제거한다.
먼저, MemberApplication에 JPA Auditing을 활성화 한다.
BaseEntity를 작성하자.
package com.example.ecommerce.common
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
class BaseEntity {
//엔티티 생성 시 자동으로 날짜 주입
@CreatedDate
@Column(updatable = false)
var createdDate: LocalDateTime? = null
//외부에서 수정 불가능하도록 protected, private set
protected set
//엔티티 수정 시 자동으로 날짜 주입
@LastModifiedDate
var updatedDate: LocalDateTime? = null
protected set
}
User @Entity 설계
BaseEntity를 상속받아 User 엔티티를 완성하면 된다.
package com.example.ecommerce.domain.user.entity
class User (
// 생성 시점에 필요한 비즈니스 필드
@Column(nullable = false, unique = true)
var email: String, //email은 변경 가능성이 있으므로 var
@Column(nullable = false)
var name: String, //이름도 개명 등으로 변경 가능성 있음
@Column(nullable = false)
var address: String,
) : BaseEntity(){
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}
UserRepository(데이터 접근 계층) 정의
Spring Data JPA의 JpaRepository를 상송받아 데이터 접근 인터페이스를 정의한다.
package com.example.ecommerce.domain.user.repository
interface UserRepository : JpaRepository<User, Long> {
//Spring Data JPA 쿼리 메서드
fun findByEmail(email: String): User?
}
UserService(비즈니스 로직) 완성
Service Layer에 UserRepository를 주입하고 확장함수를 이용해서 변환 로직을 분리한다.
package com.example.ecommerce.domain.user.dto.request
data class JoinUserRequestDto(
val email: String,
val name: String,
val address: String
)
fun JoinUserRequestDto.toEntity(): User {
return User(
email = this.email,
name = this.name,
address = this.address
)
}
package com.example.ecommerce.domain.user.dto.response
class UserResponseDto(
val id: Long,
val email: String,
val name: String
)
fun User.toResponseDto(): UserResponseDto {
return UserResponseDto(
id = requireNotNull(this.id),
email = this.email,
name = this.name
)
}
마지막으로 Service 코드이다
package com.example.ecommerce.domain.user.service
@Service
@Transactional(readOnly = true)
class UserService(private val userRepository: UserRepository) {
@Transactional
fun join(requst: JoinUserRequestDto): UserResponseDto {
if(userRepository.findByEmail(requst.email) != null) {
throw IllegalStateException("User already exists")
}
val newUser = requst.toEntity()
val savedUser = userRepository.save(newUser)
return savedUser.toResponseDto();
}
fun getUser(userId: Long): UserResponseDto{
val user = userRepository.findByIdOrNull(userId)
?: throw EntityNotFoundException("User with id $userId not found")
return user.toResponseDto()
}
}
마치며
이제 모든 계층이 연결되어 Join에 대한 feature가 완성되었고 Kotlin 덕분에 깔끔한 코드가 완성되었다. 따라서 이를 검증하기 위해서 테스트 코드를 작성해서 검증할 것이다.