Intro
안녕하세요. 환이s입니다 👋
오늘은 백엔드 개발에서 핵심적인 요소인 성능 최적화 전략에 대해 다뤄보려고 합니다.
성능 최적화는 사용자 경험을 향상시키고 시스템의 효율성을 높이는 데 매우 중요합니다. 왜냐하면 빠르고 안정적인 백엔드는 사용자를 높이고, 시스템의 유지보수 비용을 줄일 수 있기 때문인데요.
또한, 서버 자원의 효율적인 사용을 가능하게 해 주고 대규모 트래픽을 처리하는 데 필수적이라고 말씀드릴 수 있습니다.
성능최적화 전략 - 소개
제가 백엔드 개발에서 성능 최적화를 통해 시스템의 효율성과 안정성을 높이는 데 중요하게 생각하는 부분은 다음과 같습니다.
1️⃣ 데이터베이스 최적화
2️⃣ 캐싱
3️⃣ 코드최적화
크게 세 가지로 소개해드릴 수 있는데, 그 이유를 간단하게 한 줄로 말씀드리자면, 데이터베이스 쿼리를 최적화하면 응답 시간을 단축할 수 있고, 캐싱은 빠르게 접근할 수 있도록 하는 기법입니다.
코드 최적화는 불필요한 중복 코드를 제거하여 성능을 개선할 수 있는데, 자세한 설명은 아래에서 다뤄보겠습니다.
성능최적화 전략 - 데이터베이스 최적화
데이터베이스 쿼리 속도는 애플리케이션의 성능에 큰 영향을 미칠 수 있습니다.
성능이 느린 쿼리는 사용자 경험을 저하시키고 서버 자원을 낭비하며, 비즈니스 성과에 부정적인 영향을 줄 수 있습니다.
그렇다면 쿼리를 개선시켜야 하는데, 효율적이면서 안정성을 올리기 위해선 다음과 같은 방법을 시도해 보시면 좋을 거 같습니다.
1️⃣ 인덱싱 : 데이터 검색 성능을 향상시키는 기법
2️⃣ 쿼리 최적화 : 비효율적인 쿼리를 개선하여 성능을 높이는 기법
3️⃣ 정규화 : 데이터 중복을 줄이고 무결성을 유지하는 과정
4️⃣ 비정규화 : 성능을 높이기 위해 일부 데이터 중복을 허용하는 과정
이와 같은 최적화 방법들을 통해 데이터베이스의 성능을 향상시킬 수 있는데요.
예제로 알아보겠습니다.
✅인덱싱
인덱스는 데이터베이스 테이블의 특정 컬럼에 대한 검색을 빠르게 하기 위한 데이터 구조입니다.
인덱스를 사용하면 데이터 검색 시 전체 테이블을 스캔하지 않고도 원하는 데이터를 빠르게 찾을 수 있습니다.
CREATE INDEX idx_member_name ON members(name); -- name : kim
위와 같이 members 테이블의 name 컬럼에 인덱스를 추가하면 다음과 같은 쿼리의 성능이 향상됩니다.
SELECT * FROM members WHERE name = 'kim';
✅쿼리 최적화
비효율적인 쿼리는 성능 저하의 주요 원인입니다.
실제 실무에서 프로젝트를 진행하면서 비효율적인 쿼리 기반으로 동작하는 목록 팝업창이 있었는데, 이벤트를 발생시키면 6~7분 정도 시간이 소요되는 상황을 경험했었는데요
그 당시에도 쿼리를 최적화하여 데이터베이스의 부하를 줄이고 응답 시간을 1~2초로 단축했습니다.
최적화 방법은 여러 가지 있지만, 어떠한 상황에서도 제일 먼저 시도해봐야 하는 것은 JOIN 사용 최소화와 SELECT 절 최적화입니다.
✔ JOIN 사용 최소화
JOIN을 사용하여 여러 테이블에서 데이터를 가져오는 것은 유용하지만,
과도한 JOIN은 성능 저하를 초래할 수 있습니다.
필요한 데이터만 조회하도록 JOIN을 최소화하는 것이 중요한데, 먼저 비효율적인 쿼리를 확인해 보겠습니다.
- 비효율적인 쿼리 예제
SELECT o.id, o.order_date, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE p.category = 'books';
위 쿼리는 orders, customers, products 테이블을 모두 JOIN 하여 데이터를 조회합니다.
만약 products 테이블의 정보가 필요하지 않다면, JOIN을 제거하는 것이 좋습니다.
- 최적화된 쿼리 예제
SELECT o.id, o.order_date
FROM orders o
WHERE o.product_id IN (SELECT id FROM products WHERE category = 'books');
위처럼 JOIN을 제거하고 IN 절에 추가해 주면 성능을 개선할 수 있습니다.
✔ SELECT 절 최적화
SELECT절 최적화는 실무에서는 보기 힘든 상황인데, 왜냐하면 SELECT 절에 * 를 사용하는 상황은 거의 없다고 보셔도 됩니다.
실무 개발자들은 기본적으로 신입이라 하더라도 프로젝트 때 SELECT절에 *를 안 쓰기도 하지만 사용하더라도 중간 관리자가 수정하라고 말해주기도 합니다.
(회사마다 다르겠지만..)
SELECT *를 사용하는 것은 모든 컬럼을 조회하게 되어 불필요한 데이터를 가져오게 되는데, 필요한 컬럼만 선택하면 데이터 전송량과 처리 속도를 줄일 수 있습니다.
- 비효율적인 쿼리 예제
SELECT * FROM products WHERE category = 'books';
위 쿼리는 products 테이블의 모든 컬럼을 가져옵니다.
만약 필요하지 않은 데이터가 많다면, 이는 성능 저하를 초래할 수 있습니다.
- 최적화된 쿼리 예제
SELECT id, name, price FROM products WHERE category = 'books';
위처럼 필요한 컬럼만 선택하면 데이터 전송량이 줄어들어 성능이 개선됩니다.
✔ LIMIT 및 페이징
추가적으로 대량의 데이터를 조회할 경우에는 LIMIT, 페이징을 사용하면 성능을 향상시킬 수 있습니다.
- LIMIT
SELECT id, name, price FROM products ORDER BY regDt DESC LIMIT 8;
- 페이징
SELECT id, name, price FROM products ORDER BY regDt DESC LIMIT 10 OFFSET 20;
✅정규화
정규화는 데이터 중복을 줄이고 데이터 무결성을 유지하기 위해 테이블을 분리하는 과정입니다.
보통 1NF,2NF,3NF 등의 형태로 진행되는데
예를 들어, 고객과 주문 정보를 별도의 테이블로 나누어 정규화할 수 있습니다.
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT REFERENCES customers(id),
order_date TIMESTAMP
);
✅비정규화
비정규화는 성능을 향상시키기 위해 일부 데이터 중복을 허용하는 과정입니다.
주로 조회 성능을 높이기 위해 사용되는데
예를 들어, 고객의 주문 수를 포함하는 비정규화된 테이블로 알아보겠습니다.
CREATE TABLE customer_orders (
id SERIAL PRIMARY KEY,
customer_name VARCHAR(100),
order_count INT
);
이 경우, 고객 정보를 포함하여 주문 수를 함께 저장하여 조회 성능을 개선할 수 있습니다.
하지만 데이터 중복이 발생하므로 사용하는 건 지향합니다.
성능최적화 전략 - 캐싱
캐싱이란 반복적으로 사용되는 데이터나 계산 결과를 메모리에 저장하여 빠르게 액세스 할 수 있도록 하는 기법입니다.
TIP) 캐시의 개념
캐시는 애플리케이션이 자주 사용하는 데이터를 일시적으로 저장하는 메모리 공간입니다.
데이터베이스에서 직접 데이터를 조회하지 않고 캐시에서 데이터를 가져오면 성능이 향상됩니다.
데이터베이스에 접근하는데 시간이 많이 걸리는 작업일 경우, 캐싱을 사용하여 빈번하게 사용되는 데이트를 빠르게 가져올 수 있다는 장점이 있습니다.
1️⃣ 지역 캐싱(Local Caching)
2️⃣ 분산 캐싱(Distributed Caching)
캐싱 전략은 크게 지역 캐싱 (Local Caching)과 분산 캐싱 (Distributed Caching)으로 나뉘는데
두 가지 모두 성능을 향상하는 데 중요한 역할을 하지만, 사용하는 환경이나 목적에 따라 다르게 적용됩니다.
✅지역 캐싱(Local Caching)
지역 캐싱은 애플리케이션 서버의 메모리 내에 데이터를 저장하는 방식입니다.
각 서버가 개별적으로 캐시를 관리하며, 특정 서버에서 저장된 데이터는 다른 서버에서 접근할 수 없습니다.
그래서 지역 캐싱은 간단하게 구현할 수 있지만, 여러 서버 간의 캐시 데이터 동기화와 관리가 어려운 문제를 가지고 있습니다.
Java의 Spring Framework에서 ConcurrentHashMap을 사용한 지역 캐싱 예제로 알아보겠습니다.
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class LocalCacheService {
private final ConcurrentHashMap<String, String> localCache = new ConcurrentHashMap<>();
@Cacheable("localCache")
public String getData(String key) {
// 데이터베이스나 외부 API 호출을 시뮬레이션
String value = databaseCall(key);
localCache.put(key, value);
return value;
}
private String databaseCall(String key) {
// 실제 데이터베이스에서 데이터를 가져오는 로직
return "Value for " + key;
}
}
Spring에 빈을 등록하고 @Cacheable("localCacah") 어노테이션을 사용하여 메소드의 결과를 캐시에 저장하도록 설정했습니다.
첫 번째 호출 시 메소드가 실행되고, 그 결과가 "localCache"라는 이름의 캐시가 저장됩니다. 이후 동일한 인자로 메소드를 호출하면 캐시 된 결과를 반환합니다.
그리고 ConcurrentHashMap은 지역 캐시를 구현하기 위해 사용된 스레드 해시맵이며, 여러 스레드가 동시에 접근하더라도 안전하게 데이터를 저장할 수 있게 해 줍니다.
✅분산 캐싱(Distributed Caching)
분산 캐싱은 여러 서버에 걸쳐 캐시 데이터를 저장하고 관리하는 방식이며, Redis와 같은 인메모리 데이터 저장소를 사용하여 분산 캐싱을 구현할 수 있습니다.
분산 캐싱은 확장성이 좋고 여러 서버 간의 캐시 동기화가 용이 하지만, 설치 및 운영에 추가적인 비용과 복잡성이 발생할 수 있다는 단점이 있습니다.
코드로 알아보겠습니다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class DistributedCacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public String getData(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 데이터베이스 호출 시뮬레이션
value = databaseCall(key);
redisTemplate.opsForValue().set(key, value);
}
return value;
}
private String databaseCall(String key) {
// 실제 데이터베이스에서 데이터를 가져오는 로직
return "Value for " + key;
}
}
RedisTemplate<> 객체는 Redis와 상호작용을 간편하게 해 주는 클래스입니다.
의존성 주입을 해주고 value 안에 redisTemplate.opsForValue().get(key); 값을 설정했는데, 주어진 키에 대한 값을 Redis에서 조회를 하고 만약 값이 없으면 null을 반환합니다.
그리고 redisTemplate.opsForValue().set(key, value); 설정을 해줬는데, 이 부분은 데이터베이스에서 조회한 값을 Redis에 저장하고 키와 값을 인자로 받아 캐시에 저장할 수 있게 했습니다.
마지막으로 databaseCall(String key) 메소드는
실제 데이터베이스에서 데이터를 가져오는 로직을 시뮬레이션하는 메소드인데, 실제 구현에서는 데이터베이스 접근 코드가 들어갑니다.
✔ 정리하자면
각 캐싱 전략은 특정 상황에 따라 장단점이 있으므로, 애플리케이션의 요구사항에 맞게 적절하게 선택하고 적용하시면 됩니다.
성능최적화 전략 - 코드 최적화
코드 최적화는 소프트웨어 개발 과정에서 매우 중요한 부분입니다.
최적화가 잘 된 코드는 프로그램의 실행 속도를 향상시키고, 리소스 사용을 줄이며, 사용자 경험을 개선하기 때문입니다. 따라서, 개발자는 코드 최적화의 기본 원리를 이해하고 적용할 필요가 있습니다.
또한, 코드 최적화는 단순히 프로그램의 실행 속도를 빠르게 하는 것 이상의 의미를 가집니다. 왜냐하면 최적화 과정에서 코드의 가독성과 유지보수성도 고려되어야 하기 때문입니다.
최적화를 위한 다양한 접근 방법이 있는데, 개발자는 프로젝트의 요구사항과 환경에 맞는 최적의 접근 방법을 선택해서 진행하면 됩니다.
✅코드 최적화의 기본 원칙
코드 최적화를 위한 기본 원칙은 여러 가지가 있습니다.
이 중 가장 중요한 원칙은 '불필요한 계산을 피하기'입니다. 왜냐하면 불필요한 계산은 프로그램의 실행 속도를 늦추고 메모리 사용량을 증가시키기 때문입니다.
또 다른 중요한 원칙은 '루프 최적화'입니다.
루프는 프로그램의 성능에 큰 영향을 미치기 때문에 루프 내에서의 불필요한 연산을 최소화하고, 가능하다면 루프의 회전 수를 줄여 성능을 향상시킬 수 있습니다.
이외에도 '코드의 중복을 제거하는 것'도 중요한 원칙 중 하나입니다.
중복된 코드는 프로그램의 크기를 불필요하게 증가시키고, 유지보수를 어렵게 만들기 때문입니다. 따라서, 코드의 재사용성을 높이고 중복을 최소화하는 것이 중요합니다.
1️⃣ 불필요한 계산 피하기
2️⃣ 루프 최적화
3️⃣ 중복된 코드 제거하기
예제로는 사용자 알림 서비스의 중복된 코드를 제거하는 리펙토링으로 다뤄보겠습니다.
✅코드 리팩토링
코드 리팩토링은 코드의 가독성을 높이고 유지보수성 개선하기 위해 코드 구조를 수정하는 과정입니다.
좀 더 구체적인 로직을 추가해 보겠습니다.
먼저 리펙토링 전 코드입니다.
✔ 리펙토링 전
public class UserService {
public void sendEmail(String email) {
// 이메일 전송 로직
System.out.println("Sending email to " + email);
}
public void sendSMS(String phoneNumber) {
// SMS 전송 로직
System.out.println("Sending SMS to " + phoneNumber);
}
public void notifyUser(String email, String phoneNumber) {
// 사용자에게 알림 전송
sendEmail(email);
sendSMS(phoneNumber);
}
}
위 코드를 보면 간단하게 SysOut을 사용했는데, 절대 실무에서는 SysOut 사용하지 마시길 바랍니다.
(로그 레벨 관리가 어렵고, 성능 이슈가 발생합니다. 자세한 설명은 생략하겠습니다.)
위 코드의 문제점은 UserService 클래스에 이메일과 SMS 전송 로직을 모두 포함하고 있어 책임이 분산되어 있습니다. 이로 인해 알림 방식이 추가될 경우 코드가 복잡해질 수 있습니다.
✔ 리펙토링 후 : 책임 분리 및 확장 가능성 개선
//NotificationService
public interface NotificationService {
void sendNotification(String recipient);
}
//EmailService
public class EmailService implements NotificationService {
@Override
public void sendNotification(String recipient) {
// 이메일 전송 로직
System.out.println("Sending email to " + recipient);
}
}
//SMSService
public class SMSService implements NotificationService {
@Override
public void sendNotification(String recipient) {
// SMS 전송 로직
System.out.println("Sending SMS to " + recipient);
}
}
//NotificationManager
public class NotificationManager {
private List<NotificationService> notificationServices;
public NotificationManager(List<NotificationService> notificationServices) {
this.notificationServices = notificationServices;
}
public void notifyUser(String recipient) {
for (NotificationService service : notificationServices) {
service.sendNotification(recipient);
}
}
}
위 코드를 정리해 보면 다음과 같습니다.
1️⃣ 인터페이스 도입 : NotificationService 인터페이스를 생성하여 알림 서비스의 계약을 정의해서 다양한 알림 방법을 통일된 방식으로 처리할 수 있게 개선했습니다.
2️⃣ 구현 클래스 분리 : EmailService와 SMSService 클래스를 만들어 각각의 알림 로직을 독립적으로 구현해서 각 서비스의 책임을 명확하게 했습니다.
3️⃣ NotificationManager: 이 클래스는 여러 알림 서비스를 관리하며, 사용자에게 알림을 전송하는 메소드를 제공합니다. List<NotificationService>를 통해 다양한 알림 서비스를 주입받고, 사용자가 요청할 때마다 각 서비스의 sendNotification 메소드를 호출하게 했습니다.
4️⃣ 확장 용이성 : 새로운 알림 방법(예:push 알림)을 추가하려면 NotificationService를 구현하는 새로운 클래스를 추가하고, NotificationManager에 해당 서비스를 주입하면 됩니다. 기존 코드를 수정할 필요가 없어 유지보수성을 향상시킬 수 있습니다.
✔ 사용 예제
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
NotificationService emailService = new EmailService();
NotificationService smsService = new SMSService();
NotificationManager notificationManager = new NotificationManager(
Arrays.asList(emailService, smsService)
);
// 사용자에게 알림 전송
notificationManager.notifyUser("example@example.com");
notificationManager.notifyUser("+1234567890");
}
}
리펙토링을 통해 코드의 구조를 개선하고, 각 클래스의 책임을 명확히 하여 가독성과 유지보수성을 높였습니다. 이러한 방식으로 코드를 리팩토링하면 확장성이 좋아지고, 새로운 기능 추가 시의 복잡성을 줄일 수 있습니다.
마치며
오늘은 성능 최적화 전략에 대해 알아봤습니다.
성능 최적화는 단순히 애플리케이션의 속도를 향상시키는 것뿐만 아니라, 사용자 경험을 개선하고 시스템의 안정성을 높이는 데 필수적인 과정입니다.
인덱싱, 쿼리 최적화, 캐싱 전략, 코드 최적화와 같은 다양한 기법들은 각기 다른 상황에서 효과적으로 활용될 수 있습니다. 이러한 전략들을 적절히 적용하면, 애플리케이션의 성능을 극대화하고 더 나은 서비스를 제공할 수 있습니다.
앞으로도 지속적인 모니터링과 개선이 필요하며, 최신 기술과 트렌드에 대한 이해를 바탕으로 성능 최적화 전략을 계속 발전시켜 나가길 바랍니다.
다음 포스팅에서 뵙겠습니다👋
'[ Concept ]' 카테고리의 다른 글
[ Concept ] TDD(테스트 주도 개발) - 개념 및 프로그래밍 방법 알아가기 (40) | 2024.12.20 |
---|---|
[ Concept ] 프로젝트 산출물 - 화면설계서 (3) | 2024.12.04 |
[ Concept ] HTTP와 HTTPS의 차이점 (0) | 2024.08.20 |
[ Concept ] SSO(Single Sign-On) 통합 인증 알아가기 (0) | 2024.07.11 |
[ Concept ] 분산 코디네이터 Zookeeper(주키퍼) 알아가기 (0) | 2024.05.20 |