Spring Boot와 Kolin으로 고성능 백엔드 구축해보기
회사에서 일을 하면서 실제로 대규모 트래픽을 경험해 볼 수는 없다. 끽해야 일일 접속자가 350명 정도..? 사내 서비스라 이용자가 너무 적다.
여러 구인구직 공고를 보면 대부분 대규모 트래픽을 구축해보거나 경험해본 사람들을 우대하는 것을 보고 나 스스로 대규모 트래픽에 관해 여러 문서나 사례등을 바탕으로 대규모 트래픽을 처리하는 방법에 대해 많이 공부해야 된다고 느꼈다.
이 글 하나만 읽어도 대규모 트래픽 환경에서 어떻게 안정적인 시스템을 설계할 수 있는지 확실하게 정리하고자 한다.(나처럼 실제 트래픽을 경험해 본 적이 없더라도 말이다..)
1. 대규모 트래픽이란?
대규모 트래픽은 한꺼번에 수많은 사용자가 웹사이트나 애플리케이션에 접속해 데이터를 요청하는 상황을 말한다. 예를 들어, 쇼핑몰의 대규모 세일 기간이나, 로또 청약 신청, 공연 티켓 예매등 평소보다 엄청난 사용자가 몰리는 경우를 의미한다.
이런 상황에서는 제대로 준비가 되어있지 않다면 서버가 과부화되어 느려지거나 다운될 위험이 있기 때문에, 효율적인 시스템 설계가 매우매우 중요하다.
대규모 트래픽 환경에서는 다음과 같은 문제들이 발생할 수 있다.
2. 대규모 트래픽이 주는 도전 과제
- 응답 속도 유지 : 수많은 요청이 동시에 들어와도 빠르게 응답해야 한다.
- 서버 과부화 방지 : 한 서버에 모든 요청이 몰리면 서버가 다운될 수 있으므로, 부하를 적절히 분산시켜야 한다.
- 데이터 일관성 유지 : 여러 서버가 동시에 데이터를 처리할 때, 데이터의 정확성과 일관성을 유지하는 것이 필수적이다.
- 확장성 : 사용자 수가 급증해도 시스템이 원활하게 동작하도록 쉽게 확장할 수 있어야 한다.
그렇다면 위의 과제들을 해결하면서 어떻게 하면 효율적인 시스템을 설계할 수 있을까?
3. 효율적인 시스템 설계 전략
3-1. 수평 확장(Horizontal Scaling)
수평 확장의 개념은 한 서버의 성능을 높이는 대신, 여러 대의 서버를 추가하여 전체 트래픽을 분산시키는 방법이다. 비유하자면 한 교실에 너무 많은 학생이 모이면 선생님이 수업을 제대로 진행하기 어려우니, 여러 교실로 나누어 진행하는 것과 같다.
Spring Boot/Kotlin을 사용하는 백엔드 서버를 예시로 들어보자 (모든 예시는 Spring Boot 프레임워크와 Koltin을 사용하는 백엔드이다.)
수평 확장은 보통 클라우드 환경(AWS, GCP, Azure)이나 컨테이너 오케스트레이션(Kubernetes)과 함께 사용한다. Spring Boot 애플리케이션은 별도의 추가 설정 없이도 여러 인스턴스를 띄우면 수평 확장이 가능하다.
* 관련해서 더 알아보아야 하는 것 : Docker를 이용해 여러 개의 Spring Boot 컨테이너를 실행하고, Kubernetes를 사용하여 Auto Scaling 기능을 적용하는 방법을 찾아보자.
3-2. 부하 분산(Load Balancing)
부하 분산은 사용자 요청을 여러 서버에 골고루 분산시켜 한 서버에 부담이 집중되지 않도록 하는 기술이다. 음식점에서 한 웨이터가 모든 손님을 상대하기 어려우니, 여러 웨이터가 각 테이블을 나누어 케어하는 것과 같다.
Spring Boot 애플리케이션은 외부 로드 밸런서(Nginx, HAProxy, AWS ELB 등)와 함께 사용하여 부하 분산을 쉽게 구현할 수 있다.
직접 로드 밸런싱 코드를 작성하는게 아니라 로드 밸런서 설정 파일을 통해 서버 간 트래픽 분산을 관리한다.
Nginx 설정 일부 예시 :
upstream spring_backend {
server 192.168.1.101:8080;
server 192.168.1.102:8080;
server 192.168.1.103:8080;
}
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://spring_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
3-3. 캐싱(Caching)
캐싱은 자주 사용되는 데이터를 미리 저장해 두어, 데이터베이스나 다른 서버에 매번 접근하지 않고 빠르게 응답하는 기술이다. 냉장고에 좋아하는 간식을 미리 저장해두면, 매번 마트에 가지 않고도 간식을 즐길 수 있는 걸 생각하면 비슷하다.
Spring Boot는 Redis와 같은 캐시 솔루션과 쉽게 통합할 수 있다.
Redis 캐시 설정 및 사용 예시 :
1. build.gradle.kts에 Redis 의존성 추가
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// ... 기타 의존성
}
2. applicaiton.properties에 Redis 설정 추가
# Redis 서버 설정 (로컬에서 Redis가 실행 중임을 가정합니다)
spring.redis.host=localhost
spring.redis.port=6379
# 기본 서버 포트 (8080번 포트로 실행)
server.port=8080
3. Kotlin 코드 예제 - 캐시 서비스 구현
CacheService.kt
package com.example.demo.service
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
/**
* CacheService 클래스는 Redis를 이용하여 캐시 기능을 제공하는 서비스이다.
* 이 클래스에서는 데이터를 캐시에 저장(setCache)하고, 조회(getCache)하는 기능을 구현한다.
*/
@Service
class CacheService(@Autowired val redisTemplate: RedisTemplate<String, String>) {
/**
* setCache 함수는 지정된 key와 value를 캐시에 저장합니다.
* timeout은 캐시에 데이터가 유지될 시간(초)입니다.
*/
fun setCache(key: String, value: String, timeout: Long = 60) {
// opsForValue()는 단순한 key-value 캐싱에 사용됩니다.
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)
}
/**
* getCache 함수는 지정된 key에 해당하는 캐시된 값을 반환합니다.
* 만약 캐시에 값이 없으면 null을 반환합니다.
*/
fun getCache(key: String): String? {
return redisTemplate.opsForValue().get(key)
}
}
AsyncService.kt
package com.example.demo.service
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
/**
* AsyncService 클래스는 비동기 작업을 처리하기 위한 서비스이다.
* @Async 어노테이션을 사용하여, 이 클래스의 메서드가 호출될 때 즉시 반환되고,
* 별도의 스레드에서 작업을 수행하게 된다.
*/
@Service
class AsyncService {
/**
* doAsyncWork 함수는 3초 동안 대기한 후 "비동기 작업 완료!" 메시지를 콘솔에 출력한다.
* 이 함수는 @Async 어노테이션 덕분에 호출 즉시 비동기적으로 실행된다.
*/
@Async
fun doAsyncWork() {
// 3초간 대기하여, 긴 작업을 비동기적으로 처리하는 예제를 시뮬레이션한다.
Thread.sleep(3000)
println("비동기 작업 완료!")
}
}
DemoController.kt
package com.example.demo.controller
import com.example.demo.service.AsyncService
import com.example.demo.service.CacheService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* DemoController는 REST API를 제공하는 컨트롤러이다.
* 이 컨트롤러는 캐시 기능과 비동기 작업 기능을 테스트하기 위한 API를 제공한다.
*/
@RestController
class DemoController(
val cacheService: CacheService, // CacheService를 주입받아 캐시 기능을 사용한다.
val asyncService: AsyncService // AsyncService를 주입받아 비동기 작업을 수행한다.
) {
/**
* /ping API는 캐시에서 "greeting" 키의 값을 조회하고,
* 값이 없다면 "pong"을 캐시에 저장한다.
* 동시에 비동기 작업을 실행하고, "pong"을 응답한다.
*/
@GetMapping("/ping")
fun ping(): String {
val key = "greeting"
// 캐시에서 key "greeting"의 값을 가져온다.
var value = cacheService.getCache(key)
// 만약 캐시에 값이 없으면,
if (value == null) {
value = "pong"
// 캐시에 60초 동안 "pong" 값을 저장한다.
cacheService.setCache(key, value, 60)
}
// 비동기 작업을 실행한다.
// 이 작업은 백그라운드에서 3초 후 완료된다.
asyncService.doAsyncWork()
// "pong"을 응답으로 반환한다.
return value
}
}
3-4. 비동기 처리와 큐
사용자의 요청을 즉시 처리하지 않고, 큐에 저장한 후 차례대로 처리하는 방식이다. (인터파크에서 앞에 몇명이 남았다고 알려주는게 큐를 사용해서 그런게 아닐까?) 놀이공원에서 사람들이 대기열에 서 있다가 순서대로 놀이기구를 타는 것처럼, 요청을들 순차적으로 처리한다.
@Async 어노테이션을 사용하면 쉽게 비동기 작업을 구현할 수 있다.
간단한 예제를 보면
// AsyncService.kt
package com.example.demo.service
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
/**
* AsyncService는 긴 작업을 비동기적으로 처리하는 서비스이다.
* @Async 어노테이션을 사용하여, 이 메서드가 호출되면 별도의 스레드에서 실행된다.
*/
@Service
class AsyncService {
/**
* doAsyncWork 함수는 3초간 대기 후 콘솔에 "비동기 작업 완료!" 메시지를 출력한다.
*/
@Async
fun doAsyncWork() {
// 3000 밀리초 (3초) 동안 대기한다.
Thread.sleep(3000)
println("비동기 작업 완료!")
}
}
비동기에 대해서 감이 안올수 있다. Controller에서 비동기를 호출 후 처리하는 간단한 예제를 살펴보자
// DemoController.kt
package com.example.demo.controller
import com.example.demo.service.AsyncService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* DemoController는 간단한 REST API를 제공하여, 비동기 작업을 테스트할 수 있게 한다.
*/
@RestController
class DemoController(val asyncService: AsyncService) {
/**
* /asyncTest 경로를 호출하면, 비동기 작업이 실행되고 즉시 응답을 반환
*/
@GetMapping("/asyncTest")
fun asyncTest(): String {
asyncService.doAsyncWork() // 비동기 작업 실행
return "비동기 작업이 시작되었습니다!"
}
}
위 코드가 어떻게 동작할까? 사용자가 /asyncTest API를 호출하면, 서버는 즉시 "비동기 작업이 시작되었습니다!" 라는 응답을 반환하고, 백그라운드에서 3초 후 "비동기 작업 완료!" 메세지를 콘솔에 출력한다. 순서대로 실행되는게 아니다.
Spring Boot와 Kotlin에서 간단한 메시지 큐를 구현해보자(RabbitMQ 사용)
1. build.gradle.kts에 의존성 추가
dependencies {
// RabbitMQ와 Spring Boot 연동을 위한 의존성
implementation("org.springframework.boot:spring-boot-starter-amqp")
// 기타 의존성...
}
2. application.properties에 RabbitMQ 설정
# RabbitMQ 서버 설정 (기본적으로 로컬에서 실행 중이라고 가정)
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
메세지 큐 서비스 구현
// MessageQueueService.kt
package com.example.demo.service
import org.springframework.amqp.rabbit.annotation.RabbitListener
import org.springframework.amqp.rabbit.core.RabbitTemplate
import org.springframework.stereotype.Service
/**
* MessageQueueService는 RabbitMQ를 사용하여 메시지 큐를 통한 비동기 처리를 구현한 서비스
*/
@Service
class MessageQueueService(val rabbitTemplate: RabbitTemplate) {
/**
* sendMessage 함수는 지정한 메시지를 "queue.tetris" 큐로 전송한다.
*/
fun sendMessage(message: String) {
rabbitTemplate.convertAndSend("queue.tetris", message)
}
/**
* receiveMessage 함수는 "queue.tetris" 큐를 구독하여 메시지를 수신한다.
* 메시지가 수신되면 콘솔에 출력하고, 필요한 추가 처리를 수행할 수 있다.
*/
@RabbitListener(queues = ["queue.tetris"])
fun receiveMessage(message: String) {
println("메시지 수신: $message")
// 메시지 처리 로직을 여기에 작성할 수 있다.
}
}
위에서 만든 메시지 큐를 활용하는 Controller 를 만들어보자.
// QueueController.kt
package com.example.demo.controller
import com.example.demo.service.MessageQueueService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
/**
* QueueController는 메시지 큐를 통해 작업을 비동기적으로 처리하는 API를 제공
*/
@RestController
class QueueController(val messageQueueService: MessageQueueService) {
/**
* /queueTest 경로를 호출하면, "테트리스 블럭 이동"과 같은 작업 메시지를 큐에 전송한다.
*/
@GetMapping("/queueTest")
fun queueTest(): String {
// 메시지 큐에 메시지를 전송한다.
messageQueueService.sendMessage("테트리스 블럭 이동")
return "메시지가 큐에 전송되었습니다!"
}
}
사용자가 /queueTest API를 호출하면 메시지 "테트리스 블록 이동"이 RabbitMQ 큐에 전송되고, 해당 큐를 구독 중인 receiveMessage 메서드가 메시지를 수신하여 처리한다.
전체 아키텍쳐 다이어그램을 살펴보면
이런 식으로 표현할 수 있다. 사용자의 요청이 로드 밸런서를 통해 여러 서버에 분산되고, 각 서버는 캐싱, 비동기 처리. 메시지 큐, 데이터베이스를 활용해 안정적으로 응답을 제공하는 구조를 보여준다.
뭔가 부족한것 같다. 앞으로 각각의 자세한 내용을 더 공부해야겠다.