스프링 사전과제 가이드 4편: Performance & Optimization

스프링 사전과제 가이드 4편: Performance & Optimization


시리즈 네비게이션

이전현재다음
3편: Documentation & AOP4편: Performance5편: Security

📚 전체 로드맵: 스프링 사전과제 가이드 로드맵 참고


서론

1~3편의 기본 과정을 마쳤다면, 이제 심화 과정이다. 4편에서는 성능 최적화를 다룬다.

4편에서 다루는 내용:

  • N+1 문제 해결
  • 페이지네이션 전략
  • 캐싱 적용
  • 쿼리 최적화

목차


N+1 문제 해결

1. N+1 문제란?

연관관계가 있는 Entity를 조회할 때, 1번의 쿼리로 N개의 데이터를 가져온 후, 각 데이터마다 추가 쿼리가 N번 발생하는 현상이다.

// Order : OrderItem = 1 : N 관계
List<Order> orders = orderRepository.findAll(); // 1번 쿼리

for (Order order : orders) {
    // 각 Order마다 OrderItem 조회 쿼리 발생 (N번)
    List<OrderItem> items = order.getOrderItems();
    items.forEach(item -> System.out.println(item.getProductName()));
}

10개의 주문을 조회하면 1 + 10 = 11번의 쿼리가 실행된다.

2. 해결 방법

Fetch Join

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
    List<Order> findAllWithOrderItems();
}
Kotlin 버전
interface OrderRepository : JpaRepository<Order, Long> {

    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
    fun findAllWithOrderItems(): List<Order>
}

주의: Fetch Join은 페이징과 함께 사용할 수 없다. 컬렉션을 Fetch Join하면 데이터가 뻥튀기되어 메모리에서 페이징 처리된다.

@EntityGraph

@EntityGraph는 JPQL 없이 Fetch Join과 동일한 효과를 낼 수 있다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    // 1단계 연관관계: Order → OrderItems
    @EntityGraph(attributePaths = {"orderItems"})
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithOrderItemsGraph();

    // 2단계 연관관계: Order → OrderItems → Product
    @EntityGraph(attributePaths = {"orderItems", "orderItems.product"})
    List<Order> findByStatus(OrderStatus status);

    // 3단계 연관관계: Order → OrderItems → Product → Category
    @EntityGraph(attributePaths = {
        "orderItems",
        "orderItems.product",
        "orderItems.product.category"
    })
    Optional<Order> findWithFullDetailsById(Long id);
}

@EntityGraph vs Fetch Join 비교

항목@EntityGraphFetch Join
문법어노테이션JPQL 작성
유연성고정된 그래프조건에 따라 다른 쿼리
가독성좋음JPQL이 길어질 수 있음
동적 적용어려움가능

: 단순한 연관관계는 @EntityGraph, 복잡한 조건이 필요하면 Fetch Join을 사용한다.

@BatchSize

application.yml에서 전역 설정하거나, Entity에 직접 적용할 수 있다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
@Entity
public class Order {

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<>();
}

@BatchSize는 지연 로딩 시 IN 쿼리로 한 번에 가져온다:

-- 기존: N번의 쿼리
SELECT * FROM order_item WHERE order_id = 1;
SELECT * FROM order_item WHERE order_id = 2;
...

-- @BatchSize 적용 후: 1번의 쿼리
SELECT * FROM order_item WHERE order_id IN (1, 2, 3, ..., 100);
💬 Fetch Join vs @EntityGraph vs @BatchSize 선택 기준
방법장점단점사용 시점
Fetch Join한 번의 쿼리로 해결페이징 불가, 카테시안 곱 주의조회 건수가 적고 페이징이 필요 없을 때
@EntityGraph선언적, 메서드별 적용 가능Fetch Join과 동일한 한계특정 쿼리에만 즉시 로딩이 필요할 때
@BatchSize페이징 가능, 전역 설정 가능추가 쿼리 발생 (1 + 1)페이징이 필요하거나 컬렉션이 여러 개일 때

과제에서 권장: @BatchSize를 전역 설정하고, 필요한 경우에만 Fetch Join 사용

3. 지연 로딩 vs 즉시 로딩

@Entity
public class Order {

    // 즉시 로딩 (EAGER) - 권장하지 않음
    @ManyToOne(fetch = FetchType.EAGER)
    private Member member;

    // 지연 로딩 (LAZY) - 권장
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}
💡 실무 팁: 모든 연관관계는 LAZY로

기본 원칙: 모든 연관관계는 FetchType.LAZY로 설정하고, 필요한 시점에 Fetch Join이나 @EntityGraph로 함께 조회한다.

이유:

  1. EAGER는 예상치 못한 쿼리를 발생시킨다
  2. JPQL 사용 시 EAGER도 N+1 문제가 발생한다
  3. 필요한 데이터만 조회하는 것이 성능상 유리하다

주의: @ManyToOne, @OneToOne의 기본값은 EAGER이므로 명시적으로 LAZY 설정이 필요하다.


페이지네이션

1. Spring Data의 Pageable

Page 응답 방식 비교

방식장점단점
Page<T> 직접 반환간단, Spring 표준불필요한 필드 많음 (sort, pageable 등)
CommonResponse<Page<T>>일관된 응답 형식Page 내부에 중첩 정보
커스텀 PageResponse필요한 필드만추가 DTO 작성 필요

권장: 과제에서는 Page<T> 직접 반환 또는 CommonResponse<Page<T>>로 감싸는 것이 간단하고 충분하다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> getProducts(Pageable pageable) {
        return productRepository.findAll(pageable)
            .map(ProductResponse::from);
    }
}
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    // 방식 1: Page 직접 반환
    @GetMapping
    public Page<ProductResponse> getProducts(
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
            Pageable pageable) {
        return productService.getProducts(pageable);
    }

    // 방식 2: CommonResponse로 감싸기
    @GetMapping("/v2")
    public CommonResponse<Page<ProductResponse>> getProductsV2(Pageable pageable) {
        return CommonResponse.success(productService.getProducts(pageable));
    }
}
💡 커스텀 PageResponse 예시 (선택)
public record PageResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean hasNext
) {
    public static <T> PageResponse<T> from(Page<T> page) {
        return new PageResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.hasNext()
        );
    }
}
Kotlin 버전
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    fun getProducts(pageable: Pageable): Page<ProductResponse> {
        return productRepository.findAll(pageable)
            .map { ProductResponse.from(it) }
    }
}

@RestController
@RequestMapping("/api/v1/products")
class ProductController(
    private val productService: ProductService
) {
    @GetMapping
    fun getProducts(
        @PageableDefault(size = 20, sort = ["createdAt"], direction = Sort.Direction.DESC)
        pageable: Pageable
    ): Page<ProductResponse> {
        return productService.getProducts(pageable)
    }
}

2. Page vs Slice

타입특징쿼리
Page전체 개수 포함SELECT + COUNT
Slice다음 페이지 존재 여부만SELECT (size + 1)
// Page - 전체 개수가 필요한 경우 (일반적인 페이지네이션)
Page<Product> findByCategory(Category category, Pageable pageable);

// Slice - 무한 스크롤 등 전체 개수가 불필요한 경우
Slice<Product> findByCategory(Category category, Pageable pageable);

// List - 페이징 정보 없이 데이터만 필요한 경우
List<Product> findByCategory(Category category, Pageable pageable);
💡 실무 팁: COUNT 쿼리 최적화

Page를 사용하면 COUNT 쿼리가 함께 실행되는데, 복잡한 조회 쿼리의 경우 COUNT 쿼리도 느려질 수 있다.

// COUNT 쿼리 분리 최적화
@Query(value = "SELECT p FROM Product p JOIN FETCH p.category WHERE p.status = :status",
       countQuery = "SELECT COUNT(p) FROM Product p WHERE p.status = :status")
Page<Product> findByStatus(@Param("status") ProductStatus status, Pageable pageable);

대안:

  • 전체 개수가 필요 없으면 Slice 사용
  • 대략적인 개수만 필요하면 캐싱된 통계 테이블 활용

캐싱된 통계 테이블 활용 예시

대용량 데이터에서 매번 COUNT 쿼리를 실행하면 성능 문제가 발생한다. 이 경우 별도 통계 테이블을 두고 캐싱한다.

// 1. 통계 Entity 정의
@Entity
public class ProductStats {
    @Id
    private Long categoryId;
    private Long productCount;
    private LocalDateTime updatedAt;
}

// 2. 상품 등록/삭제 시 통계 갱신 (이벤트 활용)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateStats(ProductCreatedEvent event) {
    statsRepository.incrementCount(event.getCategoryId());
}

// 3. 캐시와 함께 사용
@Cacheable("productCounts")
public Long getProductCount(Long categoryId) {
    return statsRepository.findById(categoryId)
        .map(ProductStats::getProductCount)
        .orElse(0L);
}

과제에서는: 이 수준의 최적화는 필요하지 않다. Page의 기본 COUNT 쿼리로 충분하다.

3. Offset vs Cursor 기반 페이지네이션

Offset 기반 (기본)

// page=100, size=20 요청 시
// OFFSET 2000 LIMIT 20 -> 2000개를 스킵해야 함

문제점: 데이터가 많아지면 OFFSET이 커져서 성능이 저하된다.

Cursor 기반

public interface ProductRepository extends JpaRepository<Product, Long> {

    // ID 기반 커서 페이지네이션
    @Query("SELECT p FROM Product p WHERE p.id < :cursor ORDER BY p.id DESC")
    List<Product> findByIdLessThan(@Param("cursor") Long cursor, Pageable pageable);
}
@Service
public class ProductService {

    public CursorResponse<ProductResponse> getProductsWithCursor(Long cursor, int size) {
        Pageable pageable = PageRequest.of(0, size + 1); // 다음 페이지 확인용 +1

        List<Product> products = cursor == null
            ? productRepository.findAll(PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id"))).getContent()
            : productRepository.findByIdLessThan(cursor, pageable);

        boolean hasNext = products.size() > size;
        if (hasNext) {
            products = products.subList(0, size);
        }

        Long nextCursor = hasNext ? products.get(products.size() - 1).getId() : null;

        return new CursorResponse<>(
            products.stream().map(ProductResponse::from).toList(),
            nextCursor,
            hasNext
        );
    }
}
CursorResponse 클래스
@Getter
@AllArgsConstructor
public class CursorResponse<T> {
    private List<T> content;
    private Long nextCursor;
    private boolean hasNext;
}
💬 Offset vs Cursor 선택 기준
방식장점단점사용 시점
Offset구현 간단, 특정 페이지 이동 가능대용량에서 느림, 데이터 중복/누락 가능관리자 페이지, 데이터가 적은 경우
Cursor대용량에서 빠름, 일관된 결과특정 페이지 이동 불가무한 스크롤, SNS 피드, 대용량 데이터

과제에서 권장: 기본적으로 Offset(Page) 사용, README에 Cursor 방식의 존재와 트레이드오프를 언급하면 가산점


캐싱 전략

1. Spring Cache 추상화

@Configuration
@EnableCaching
public class CacheConfig {
    // 기본 설정으로 ConcurrentHashMap 기반 캐시 사용
}
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    /**
     * 상품 상세 조회 - 캐시 적용
     * key: productId, 캐시명: product
     */
    @Cacheable(value = "product", key = "#productId")
    public ProductDetailResponse getProductDetail(Long productId) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        return ProductDetailResponse.from(product);
    }

    /**
     * 상품 수정 - 캐시 갱신
     */
    @CachePut(value = "product", key = "#productId")
    public ProductDetailResponse updateProduct(Long productId, ProductUpdateCommand command) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        product.update(command.getName(), command.getPrice());
        return ProductDetailResponse.from(product);
    }

    /**
     * 상품 삭제 - 캐시 제거
     */
    @CacheEvict(value = "product", key = "#productId")
    public void deleteProduct(Long productId) {
        productRepository.deleteById(productId);
    }

    /**
     * 전체 상품 캐시 제거
     */
    @CacheEvict(value = "product", allEntries = true)
    public void clearProductCache() {
        // 캐시만 제거
    }
}

2. Caffeine 캐시 적용

// build.gradle
implementation 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-cache'
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)           // 최대 1000개 항목
            .expireAfterWrite(10, TimeUnit.MINUTES)  // 10분 후 만료
            .recordStats());             // 통계 기록
        return cacheManager;
    }
}

캐시별 설정 분리

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();

        List<CaffeineCache> caches = List.of(
            buildCache("product", 500, 30, TimeUnit.MINUTES),
            buildCache("category", 100, 1, TimeUnit.HOURS),
            buildCache("config", 50, 24, TimeUnit.HOURS)
        );

        cacheManager.setCaches(caches);
        return cacheManager;
    }

    private CaffeineCache buildCache(String name, int maxSize, long duration, TimeUnit unit) {
        return new CaffeineCache(name, Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(duration, unit)
            .recordStats()
            .build());
    }
}

3. Redis 캐시 적용

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
# application.yml
spring:
  redis:
    host: localhost
    port: 6379
  cache:
    type: redis
    redis:
      time-to-live: 600000  # 10분 (밀리초)
      cache-null-values: false
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> cacheConfigurations = Map.of(
            "product", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "category", defaultConfig.entryTtl(Duration.ofHours(1))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
}
💬 로컬 캐시 vs 분산 캐시
구분로컬 캐시 (Caffeine)분산 캐시 (Redis)
속도매우 빠름 (메모리 직접 접근)상대적으로 느림 (네트워크 통신)
일관성서버 간 불일치 가능일관성 보장
용량서버 메모리 제한별도 서버로 확장 가능
복잡도간단Redis 인프라 필요

과제에서 권장:

  • 단일 서버 과제라면 Caffeine으로 충분
  • Docker Compose에 Redis를 포함시키면 가산점
💡 캐시 무효화 전략

Cache-Aside (Lazy Loading):

  1. 캐시에서 먼저 조회
  2. 없으면 DB에서 조회 후 캐시에 저장
  3. 수정/삭제 시 캐시 무효화

Write-Through:

  1. 데이터 저장 시 캐시와 DB 동시 업데이트

주의사항:

  • 목록 조회 캐시는 무효화가 어려움 (개별 항목 변경 시 전체 무효화 필요)
  • 캐시 TTL을 적절히 설정하여 자연 만료 유도
  • 캐시 키 설계 시 충돌 방지 (prefix 사용)

쿼리 최적화

1. Projection 활용

전체 Entity 대신 필요한 필드만 조회한다.

Interface Projection

// 필요한 필드만 정의한 인터페이스
public interface ProductSummary {
    Long getId();
    String getName();
    Integer getPrice();
}

public interface ProductRepository extends JpaRepository<Product, Long> {

    List<ProductSummary> findByCategory(Category category);
}

Class Projection (DTO)

public record ProductSummaryDto(
    Long id,
    String name,
    Integer price
) {}

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT new com.example.dto.ProductSummaryDto(p.id, p.name, p.price) " +
           "FROM Product p WHERE p.category = :category")
    List<ProductSummaryDto> findSummaryByCategory(@Param("category") Category category);
}
💡 Projection 성능 비교
// 1. Entity 전체 조회 - 모든 컬럼 + 연관 Entity
List<Product> products = productRepository.findAll();

// 2. Interface Projection - 필요한 컬럼만 (Proxy 생성)
List<ProductSummary> summaries = productRepository.findAllProjectedBy();

// 3. DTO Projection - 필요한 컬럼만 (직접 생성)
List<ProductSummaryDto> dtos = productRepository.findAllSummary();

성능: DTO Projection > Interface Projection > Entity 전체 조회

단, 조회 후 Entity 수정이 필요하면 Entity로 조회해야 한다.

2. QueryDSL 동적 쿼리

// build.gradle
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
@Repository
@RequiredArgsConstructor
public class ProductQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Product> searchProducts(ProductSearchCondition condition) {
        return queryFactory
            .selectFrom(product)
            .where(
                categoryEq(condition.getCategoryId()),
                priceGoe(condition.getMinPrice()),
                priceLoe(condition.getMaxPrice()),
                nameContains(condition.getKeyword())
            )
            .orderBy(product.createdAt.desc())
            .offset(condition.getOffset())
            .limit(condition.getLimit())
            .fetch();
    }

    private BooleanExpression categoryEq(Long categoryId) {
        return categoryId != null ? product.category.id.eq(categoryId) : null;
    }

    private BooleanExpression priceGoe(Integer minPrice) {
        return minPrice != null ? product.price.goe(minPrice) : null;
    }

    private BooleanExpression priceLoe(Integer maxPrice) {
        return maxPrice != null ? product.price.loe(maxPrice) : null;
    }

    private BooleanExpression nameContains(String keyword) {
        return StringUtils.hasText(keyword) ? product.name.contains(keyword) : null;
    }
}
Kotlin + QueryDSL
@Repository
class ProductQueryRepository(
    private val queryFactory: JPAQueryFactory
) {
    fun searchProducts(condition: ProductSearchCondition): List<Product> {
        return queryFactory
            .selectFrom(product)
            .where(
                categoryEq(condition.categoryId),
                priceGoe(condition.minPrice),
                priceLoe(condition.maxPrice),
                nameContains(condition.keyword)
            )
            .orderBy(product.createdAt.desc())
            .offset(condition.offset)
            .limit(condition.limit)
            .fetch()
    }

    private fun categoryEq(categoryId: Long?) =
        categoryId?.let { product.category.id.eq(it) }

    private fun priceGoe(minPrice: Int?) =
        minPrice?.let { product.price.goe(it) }

    private fun priceLoe(maxPrice: Int?) =
        maxPrice?.let { product.price.loe(it) }

    private fun nameContains(keyword: String?) =
        keyword?.takeIf { it.isNotBlank() }?.let { product.name.contains(it) }
}
💬 QueryDSL vs JPQL vs Native Query
방식장점단점사용 시점
JPQLJPA 표준, Entity 매핑문자열 기반, 동적 쿼리 어려움단순한 정적 쿼리
QueryDSL타입 안전, 동적 쿼리 용이설정 복잡, Q클래스 생성 필요복잡한 동적 쿼리
Native QuerySQL 직접 작성, 최적화 가능DB 종속, Entity 매핑 제한복잡한 통계, 특정 DB 기능 필요 시

과제에서 권장: 단순 CRUD는 Spring Data JPA, 복잡한 검색 조건이 있으면 QueryDSL 도입

3. 인덱스 설계

@Entity
@Table(name = "product", indexes = {
    @Index(name = "idx_product_category", columnList = "category_id"),
    @Index(name = "idx_product_status_created", columnList = "status, created_at"),
    @Index(name = "idx_product_name", columnList = "name")
})
public class Product {
    // ...
}
💡 인덱스 설계 팁

인덱스가 필요한 경우:

  • WHERE 절에 자주 사용되는 컬럼
  • JOIN 조건에 사용되는 컬럼 (FK)
  • ORDER BY에 사용되는 컬럼
  • 카디널리티가 높은 컬럼 (고유값이 많은)

인덱스 주의사항:

  • INSERT/UPDATE/DELETE 성능 저하
  • 복합 인덱스는 컬럼 순서가 중요 (왼쪽부터 사용)
  • 과도한 인덱스는 오히려 성능 저하

과제에서: Entity에 @Index를 선언하면 DDL 자동 생성 시 인덱스가 포함되어 의도를 보여줄 수 있다.


정리

체크리스트

항목확인
모든 연관관계가 FetchType.LAZY로 설정되어 있는가?
@BatchSize 전역 설정이 적용되어 있는가?
페이지네이션이 필요한 API에 Pageable이 적용되어 있는가?
자주 조회되는 데이터에 캐싱이 적용되어 있는가?
목록 조회 시 필요한 필드만 Projection으로 가져오는가?
복잡한 동적 쿼리에 QueryDSL이 사용되었는가?

핵심 포인트

  1. N+1 문제: 모든 연관관계는 LAZY, 필요 시 Fetch Join 또는 @BatchSize
  2. 페이지네이션: Page(Offset) 기본, 대용량이면 Cursor 고려
  3. 캐싱: 변경이 적고 조회가 많은 데이터에 적용
  4. 쿼리 최적화: 필요한 데이터만 조회 (Projection, 조건 절 최적화)
⚠️ 과제에서 흔한 실수
  1. EAGER 로딩 그대로 사용

    • @ManyToOne, @OneToOne 기본값이 EAGER
    • 반드시 명시적으로 LAZY 설정
  2. 무분별한 Fetch Join

    • 컬렉션 여러 개를 Fetch Join하면 카테시안 곱 발생
    • MultipleBagFetchException 발생 가능
  3. COUNT 쿼리 무시

    • Page 사용 시 COUNT 쿼리도 함께 실행됨
    • 복잡한 조회 시 COUNT 쿼리 분리 또는 Slice 사용
  4. 캐시 키 충돌

    • 서로 다른 메서드에서 같은 캐시명 + 같은 키 사용
    • 메서드별로 고유한 캐시명 또는 키 전략 필요

다음 편에서는 Spring Security, JWT 인증, 비밀번호 관리 에 대해 다룹니다.

👉 이전: 3편 - Documentation & AOP 👉 다음: 5편 - Security & Authentication

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.