반응형

캐시 한 줄이 DB 부하를 90% 줄인다 — Redis 실전 적용

 

"DB CPU가 90%예요."

 

모니터링 대시보드를 열었다. CPU 90%, 커넥션 풀 포화, 응답 시간 급등.

 

슬로우 쿼리 로그를 열었다. 느린 쿼리는 없었다. 전부 0.05초 이내의 빠른 쿼리들.

 

그런데 같은 쿼리가 초당 1,200번 실행되고 있었다.

 

상품 상세 페이지. 인기 상품 하나에 접속이 몰리면서, 같은 SELECT를 1,200번씩 날리고 있었다. 쿼리가 빠르든 느리든, 양이 많으면 DB는 죽는다.

 

답은 캐시였다.

 

 


 

1. 캐시란

 

캐시 없을 때:
  사용자 1 → DB 조회 → 응답
  사용자 2 → DB 조회 → 응답  (같은 데이터)
  사용자 3 → DB 조회 → 응답  (같은 데이터)
  ...
  사용자 1000 → DB 조회 → 응답  (같은 데이터)
  = DB 1,000번 호출

캐시 있을 때:
  사용자 1 → DB 조회 → 캐시에 저장 → 응답
  사용자 2 → 캐시에서 꺼냄 → 응답  (DB 안 감)
  사용자 3 → 캐시에서 꺼냄 → 응답  (DB 안 감)
  ...
  사용자 1000 → 캐시에서 꺼냄 → 응답  (DB 안 감)
  = DB 1번 호출

 

자주 읽히는 데이터를 메모리에 올려두고, DB까지 가지 않는 것. 그게 캐시다.

 

 


 

2. 어디에 캐시할 수 있는가

 

계층 위치 특징 예시
브라우저 클라이언트 네트워크 요청 자체를 안 함 Cache-Control 헤더
CDN 엣지 서버 정적 파일, 이미지 CloudFront, CloudFlare
앱 메모리 서버 프로세스 가장 빠름, 서버 간 공유 안 됨 Map, HashMap
Redis 별도 서버 서버 간 공유, TTL 지원 Redis, Memcached
DB 쿼리 캐시 DB 서버 자동, 하지만 제한적 MySQL Query Cache

 

실무에서 가장 많이 쓰는 건 Redis 캐시다. 서버가 여러 대여도 공유되고, TTL로 자동 만료된다.

 

 


 

3. Redis 캐시 실전 구현

 

Spring Boot (@Cacheable)

 

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    // ✅ 캐시 적용: 같은 id로 호출하면 DB 안 감
    @Cacheable(value = "product", key = "#id")
    public ProductDTO getProduct(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("상품 없음"));
        return ProductDTO.from(product);
    }

    // ✅ 상품 수정 시 캐시 삭제
    @CacheEvict(value = "product", key = "#id")
    public void updateProduct(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("상품 없음"));
        product.update(request);
        productRepository.save(product);
    }
}

 

// Redis 설정
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))           // TTL 10분
                .serializeValuesWith(
                    SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                );

        return RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
    }
}

 

Node.js (ioredis)

 

import Redis from 'ioredis';

const redis = new Redis({ host: '127.0.0.1', port: 6379 });

async function getProduct(id: string): Promise<Product> {
    const cacheKey = `product:${id}`;

    // 1. 캐시 확인
    const cached = await redis.get(cacheKey);
    if (cached) {
        return JSON.parse(cached);  // 캐시 히트 → DB 안 감
    }

    // 2. 캐시 미스 → DB 조회
    const product = await db.query('SELECT * FROM products WHERE id = ?', [id]);

    // 3. 캐시에 저장 (TTL 600초 = 10분)
    await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);

    return product;
}

async function updateProduct(id: string, data: Partial<Product>): Promise<void> {
    await db.query('UPDATE products SET ... WHERE id = ?', [id]);

    // 캐시 삭제 (다음 조회 시 새 데이터로 캐시됨)
    await redis.del(`product:${id}`);
}

 

캐시 적용 전후 흐름

 

Before (캐시 없음):
  요청 → Controller → Service → Repository → DB → 응답
  매번 DB 접근. 초당 1,200번.

After (캐시 적용):
  요청 → Controller → Service → Redis에 있나? → 있으면 바로 응답 (95%)
                                              → 없으면 DB → Redis 저장 → 응답 (5%)
  DB 접근: 초당 1,200번 → 초당 60번

 

 


 

4. 캐시 전략

 

Cache-Aside (가장 일반적)

 

읽기:
  1. 캐시에서 찾는다
  2. 있으면 반환 (Cache Hit)
  3. 없으면 DB에서 읽고, 캐시에 저장 후 반환 (Cache Miss)

쓰기:
  1. DB에 쓴다
  2. 캐시를 삭제한다 (다음 읽기 시 새 데이터로 캐시됨)

 

// Cache-Aside 패턴 구현
async function getCachedData(key: string, fetchFn: () => Promise<any>, ttl: number) {
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    const data = await fetchFn();
    await redis.set(key, JSON.stringify(data), 'EX', ttl);
    return data;
}

// 사용
const product = await getCachedData(
    `product:${id}`,
    () => productRepository.findById(id),
    600  // 10분
);

 

Write-Through (쓰기 동시)

 

쓰기:
  1. 캐시에 쓴다
  2. DB에도 쓴다
  3. 둘 다 성공하면 완료

장점: 캐시와 DB가 항상 일치
단점: 쓰기가 느림 (두 번 쓰니까)

 

Write-Behind (쓰기 지연)

 

쓰기:
  1. 캐시에만 쓴다 → 바로 응답
  2. 나중에 비동기로 DB에 반영

장점: 쓰기가 매우 빠름
단점: 캐시 서버 죽으면 데이터 유실 위험

 

실무에서는 Cache-Aside를 90% 쓴다. 가장 안전하고 구현이 단순하다.

 

TTL 설정 가이드

 

데이터 유형 TTL 이유
상품 상세 5~10분 가격 변경이 잦지 않음
카테고리 목록 1시간 거의 안 바뀜
인기 검색어 1~5분 적당히 실시간
사용자 프로필 5분 수정 빈도 낮음
설정/코드 테이블 1일 거의 안 바뀜

 

 


 

5. 캐시 주의사항 3가지

 

(1) 캐시 무효화 — 가장 어려운 문제

 

"컴퓨터 과학에서 어려운 것은 두 가지뿐이다: 캐시 무효화와 이름 짓기."
— Phil Karlton

 

문제 상황:
  1. 상품 가격 10,000원 → 캐시에 저장됨
  2. 관리자가 가격을 15,000원으로 수정
  3. 캐시를 안 지우면? → 사용자는 10,000원을 본다
  4. 10,000원으로 결제하면? → 장애

 

// 해결: 데이터 수정 시 반드시 캐시 삭제
async function updateProductPrice(id: string, newPrice: number) {
    // 1. DB 업데이트
    await db.query('UPDATE products SET price = ? WHERE id = ?', [newPrice, id]);

    // 2. 캐시 삭제 (필수!)
    await redis.del(`product:${id}`);

    // 3. 목록 캐시도 삭제해야 할 수 있다
    await redis.del(`product-list:category:${categoryId}`);
}

 

(2) 캐시 스탬피드 — 동시에 몰리는 문제

 

TTL 만료 순간:
  09:00:00.000 — 캐시 만료됨
  09:00:00.001 — 요청 100개가 동시에 캐시 미스
  09:00:00.002 — 100개 요청이 전부 DB 조회
  09:00:00.003 — DB에 같은 쿼리 100개가 동시에 도착
  → DB 부하 폭증

 

// 해결: 뮤텍스 (락) 패턴
async function getProductWithLock(id: string): Promise<Product> {
    const cacheKey = `product:${id}`;
    const lockKey = `lock:product:${id}`;

    const cached = await redis.get(cacheKey);
    if (cached) return JSON.parse(cached);

    // 락 획득 시도 (NX: 없을 때만, EX: 5초 후 자동 해제)
    const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);

    if (acquired) {
        // 락 획득 성공 → DB 조회 후 캐시
        const product = await db.query('SELECT * FROM products WHERE id = ?', [id]);
        await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);
        await redis.del(lockKey);
        return product;
    } else {
        // 락 획득 실패 → 잠시 후 캐시에서 재시도
        await sleep(100);
        return getProductWithLock(id);
    }
}

 

(3) 캐시 웜업 — 서버 시작 시 빈 캐시

 

서버 재시작 → 캐시 비어있음 → 모든 요청이 DB로 → DB 과부하

해결: 서버 시작 시 주요 데이터를 미리 캐시에 로드

 

// 서버 시작 시 캐시 웜업
async function warmUpCache() {
    console.log('캐시 웜업 시작...');

    // 인기 상품 100개 미리 캐시
    const popularProducts = await db.query(
        'SELECT * FROM products ORDER BY view_count DESC LIMIT 100'
    );

    for (const product of popularProducts) {
        await redis.set(`product:${product.id}`, JSON.stringify(product), 'EX', 600);
    }

    // 카테고리 목록 캐시
    const categories = await db.query('SELECT * FROM categories WHERE active = 1');
    await redis.set('categories:all', JSON.stringify(categories), 'EX', 3600);

    console.log(`캐시 웜업 완료: 상품 ${popularProducts.length}개, 카테고리 ${categories.length}개`);
}

 

 


 

6. 실전 Before/After

 

사례 1: 상품 목록 — DB 직접 조회 → Redis 캐시

 

-- 카테고리별 상품 목록 (매번 DB)
SELECT id, name, price, thumbnail
FROM products
WHERE category_id = 5 AND status = 'ACTIVE'
ORDER BY sort_order ASC;

 

Before:

 

응답 시간: 200ms (쿼리 자체는 빠름)
초당 요청: 800회
DB CPU: 78%
DB 커넥션: 45/50 (거의 포화)

 

// 캐시 적용
const cacheKey = `products:category:${categoryId}`;
const products = await getCachedData(cacheKey, () => {
    return db.query('SELECT ... FROM products WHERE category_id = ?', [categoryId]);
}, 300);  // 5분 TTL

 

After:

 

응답 시간: 5ms (Redis에서 바로 응답)
초당 요청: 800회 (동일)
DB CPU: 12%
DB 커넥션: 8/50
캐시 히트율: 96%

 

응답 200ms → 5ms (40배 빨라짐), DB CPU 78% → 12%, DB 부하 95% 감소.

 

사례 2: 인기 검색어 — 매번 집계 → 1분 캐시

 

-- 최근 1시간 인기 검색어 Top 10 (매 요청마다 집계)
SELECT keyword, COUNT(*) AS cnt
FROM search_logs
WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
GROUP BY keyword
ORDER BY cnt DESC
LIMIT 10;

 

Before:

 

쿼리 실행 시간: 1.5초 (100만 건 집계)
초당 요청: 200회
DB CPU: 82%

 

// 1분 캐시 적용 (인기 검색어는 1분 안에 크게 안 바뀜)
const popular = await getCachedData('search:popular', () => {
    return db.query('SELECT keyword, COUNT(*) ...');
}, 60);  // 1분 TTL

 

After:

 

응답 시간: 3ms (캐시 히트)
초당 요청: 200회 (동일)
DB CPU: 15%
실제 DB 집계: 1분에 1번만

 

DB CPU 82% → 15%. 집계 쿼리가 초당 200번에서 분당 1번으로 감소.

 

 


 

7. 캐시하면 안 되는 것

 

모든 데이터를 캐시할 수 있는 건 아니다.

 

데이터 캐시 가능 여부 이유
상품 목록 O 잠깐 옛날 데이터 보여도 괜찮음
카테고리 O 거의 안 바뀜
인기 검색어 O 1분 오차 허용
계좌 잔액 X 실시간 정확성 필수
재고 수량 X 0개인데 "있음"으로 보이면 장애
결제 상태 X 중복 결제 위험
인증 토큰 조건부 캐시하되 즉시 무효화 가능해야 함

 

핵심 판단 기준:
  "이 데이터가 10초 동안 옛날 값이어도 괜찮은가?"

  괜찮다 → 캐시 가능
  안 괜찮다 → 캐시하면 안 됨

 

 


 

8. 캐시 모니터링

 

캐시를 넣었으면 모니터링해야 한다.

 

# Redis CLI로 상태 확인
redis-cli INFO stats

# 핵심 지표
keyspace_hits:   4,521,340   # 캐시 히트
keyspace_misses: 231,500     # 캐시 미스

# 히트율 = hits / (hits + misses) = 95.1%

 

히트율 상태
95% 이상 잘 되고 있음
80~95% 보통, TTL 조정 검토
80% 미만 뭔가 잘못됨. TTL이 너무 짧거나 키 설계가 잘못됨

 

# 메모리 사용량 확인
redis-cli INFO memory
# used_memory_human: 256.50M
# maxmemory_human: 1.00G

# 키 개수 확인
redis-cli DBSIZE
# (integer) 45230

 

 


 

다음 편 예고: 테스트 코드 — 안 짜면 어떻게 되는지 겪어본 이야기

마지막 편은 DB 성능이 아니다. "개발 생산성 성능"이다. 테스트 없이 개발하면 수정이 무섭고, 배포가 무섭고, 리팩토링이 불가능하다. 테스트 한 줄이 개발 속도를 어떻게 바꾸는지, 실제 경험을 기반으로 정리한다.

 

 


캐시, Redis, Cache-Aside, TTL, 캐시무효화, 캐시스탬피드, ioredis, Spring Cache, Cacheable, CacheEvict, 성능최적화, DB부하, 메모리캐시, 쿼리캐시, 히트율

반응형
반응형

실무에서 자주 터지는 Spring 버그 모음 — 면접에도 나오는 것들

 

Spring으로 개발하면 한 번씩은 꼭 만나는 버그들이 있다.

 

에러 메시지를 보고 "아 이거..." 하는 순간이 반드시 온다.

시니어도 가끔 실수하고, 면접에서도 자주 나오는 주제들만 모았다.

 

 


 

1. 순환 참조 (Circular Dependency)

 

증상

 

애플리케이션 시작 시:

***************************
APPLICATION FAILED TO START
***************************

The dependencies of some of the beans in the application context
form a cycle:

┌─────┐
|  memberService
↑     ↓
|  orderService
└─────┘

 

원인

 

@Service
@RequiredArgsConstructor
public class MemberService {
    private final OrderService orderService;  // MemberService → OrderService

    public void deleteMember(Long id) {
        orderService.cancelAllOrders(id);
        // ...
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final MemberService memberService;  // OrderService → MemberService

    public void createOrder(OrderDto dto) {
        memberService.findById(dto.getMemberId());
        // ...
    }
}

 

MemberService 생성하려면 → OrderService 필요
OrderService 생성하려면 → MemberService 필요
→ 무한 루프 → 앱 시작 실패

 

해결

 

// 해결법 1: 설계 개선 — 공통 로직을 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    // OrderService 의존 제거

    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow();
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final MemberService memberService;  // 단방향

    public void createOrder(OrderDto dto) {
        Member member = memberService.findById(dto.getMemberId());
        // ...
    }
}

// 회원 삭제 + 주문 취소는 별도 서비스에서
@Service
@RequiredArgsConstructor
public class MemberDeletionService {
    private final MemberService memberService;
    private final OrderService orderService;

    @Transactional
    public void deleteMember(Long id) {
        orderService.cancelAllOrders(id);
        memberService.delete(id);
    }
}

 

// 해결법 2: @Lazy (임시방편)
@Service
public class MemberService {
    private final OrderService orderService;

    public MemberService(@Lazy OrderService orderService) {
        this.orderService = orderService;
    }
}
// → 실제 사용 시점에 프록시로 주입
// → 순환은 해결되지만 설계 문제가 남아있음

 

예방

 

1. 의존 방향은 항상 단방향으로
2. A → B → A 구조가 보이면 설계를 다시 생각
3. 공통 로직은 별도 서비스로 분리
4. @Lazy는 급할 때만 — 근본적 해결은 설계 개선

 

 


 

2. Bean 등록이 안 됨

 

증상

 

Parameter 0 of constructor in com.example.service.MemberService
required a bean of type 'com.example.repository.MemberRepository'
that could not be found.

Action:
Consider defining a bean of type 'com.example.repository.MemberRepository'
in your configuration.

 

원인들

 

// 원인 1: @Component 빠뜨림
public class EmailService {  // ← @Service 없음!
    public void send(String to, String subject) { ... }
}

// 해결
@Service  // ← 추가
public class EmailService {
    public void send(String to, String subject) { ... }
}

 

// 원인 2: 패키지 스캔 범위 밖
// 메인 클래스가 com.example.app에 있는데
@SpringBootApplication  // com.example.app 하위만 스캔
public class Application { ... }

// Bean이 com.other.service에 있으면 → 스캔 안 됨
package com.other.service;

@Service
public class ExternalService { ... }  // 못 찾음!

 

// 해결: 스캔 범위 지정
@SpringBootApplication(scanBasePackages = {
    "com.example",
    "com.other"
})
public class Application { ... }

 

// 원인 3: @Configuration에서 반환 타입 잘못
@Configuration
public class AppConfig {

    @Bean
    public Object emailService() {  // ← 반환 타입이 Object!
        return new EmailService();
    }
    // EmailService 타입으로 주입 시 못 찾음
}

// 해결
@Configuration
public class AppConfig {

    @Bean
    public EmailService emailService() {  // ← 정확한 타입
        return new EmailService();
    }
}

 

// 원인 4: 인터페이스 vs 구현체 혼동
public interface PaymentGateway { ... }

@Service
public class StripeGateway implements PaymentGateway { ... }

// 주입할 때
@RequiredArgsConstructor
public class OrderService {
    private final StripeGateway gateway;  // ← 구현체로 주입하면
    // 프록시(AOP) 적용 시 못 찾을 수 있음
}

// 해결: 인터페이스로 주입
private final PaymentGateway gateway;  // ← 인터페이스로

 

 


 

3. equals/hashCode 미구현

 

증상

 

Set<Member> memberSet = new HashSet<>();

Member member1 = memberRepository.findById(1L).orElseThrow();
Member member2 = memberRepository.findById(1L).orElseThrow();

memberSet.add(member1);
memberSet.add(member2);

System.out.println(memberSet.size());
// 기대: 1 (같은 회원이니까)
// 실제: 2 (다른 객체로 판단!)

 

원인

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    // equals/hashCode 미구현
    // → Object의 기본 equals 사용 = 참조(주소) 비교
    // → 같은 id라도 다른 객체면 다르다고 판단
}

 

Object.equals() 기본 동작:
  member1 == member2  →  false (다른 객체)
  → Set에 2개 들어감

원하는 동작:
  member1.id == member2.id  →  true (같은 회원)
  → Set에 1개만 들어가야 함

 

해결

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Member)) return false;  // instanceof 사용!
        Member member = (Member) o;
        return id != null && id.equals(member.getId());
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();  // 고정값!
    }
}

 

왜 instanceof인가?

 

// getClass()로 비교하면:
if (o == null || getClass() != o.getClass()) return false;

// JPA 프록시 문제:
Member member = em.find(Member.class, 1L);
Member proxy = em.getReference(Member.class, 1L);

member.getClass();  // Member
proxy.getClass();   // Member$HibernateProxy$abc123

member.getClass() == proxy.getClass();  // false!
// → 같은 엔티티인데 다르다고 판단

// instanceof로 비교하면:
proxy instanceof Member;  // true!
// → 프록시도 Member의 하위 클래스이므로 true

 

왜 hashCode()에 고정값을 쓰는가?

 

// id로 hashCode를 만들면:
@Override
public int hashCode() {
    return Objects.hash(id);
}

// 문제:
Member member = new Member();  // id = null → hashCode = X
memberSet.add(member);

em.persist(member);  // id = 1 → hashCode = Y
memberSet.contains(member);  // false! hashCode가 바뀌었으니까!

// 고정값이면:
@Override
public int hashCode() {
    return getClass().hashCode();  // 항상 같은 값
}
// → id가 바뀌어도 hashCode 동일 → Set/Map에서 안전
// → 단점: 같은 버킷에 몰리므로 성능 O(n). 하지만 실무에서 문제되는 경우 드묾.

 

 


 

4. LocalDateTime 직렬화 문제

 

증상

 

// 기대한 응답
{ "createdAt": "2024-04-20T10:30:00" }

// 실제 응답
{ "createdAt": [2024, 4, 20, 10, 30, 0] }

 

또는

 

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"

 

원인

 

Jackson은 기본적으로 Java 8 날짜 타입(LocalDateTime, LocalDate 등)을 모른다. 배열로 직렬화하거나 에러가 난다.

 

해결

 

// 방법 1: 필드에 @JsonFormat (필드별 적용)
public class MemberResponse {

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthDate;
}

 

// 방법 2: 글로벌 설정 (전체 적용, 권장)
@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return mapper;
    }
}

 

# 방법 3: application.yml 설정
spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false
    date-format: yyyy-MM-dd HH:mm:ss

 

// 설정 후 응답
{ "createdAt": "2024-04-20 10:30:00" }

 

추가: 타임존 문제

 

// 서버 타임존과 DB 타임존이 다르면 시간이 어긋남
// application.yml에 타임존 명시
spring:
  jackson:
    time-zone: Asia/Seoul

 

 


 

5. 동시성 문제

 

증상

 

재고가 1개 남은 상품에 2명이 동시에 주문.
둘 다 성공함. 재고가 -1이 됨.

 

원인

 

@Service
public class StockService {

    @Transactional
    public void decrease(Long productId) {
        Product product = productRepository.findById(productId).orElseThrow();

        if (product.getStock() <= 0) {
            throw new RuntimeException("재고 없음");
        }

        product.setStock(product.getStock() - 1);
    }
}

 

Thread A                          Thread B
─────────────────────────────     ─────────────────────────────
SELECT stock FROM product         
→ stock = 1                       
                                  SELECT stock FROM product
                                  → stock = 1
if (stock <= 0) → false           
stock = 1 - 1 = 0                 if (stock <= 0) → false
UPDATE stock = 0                  stock = 1 - 1 = 0
                                  UPDATE stock = 0

결과: 재고 0. 근데 2건 주문 처리됨!

 

해결법 1: 낙관적 락 (@Version)

 

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private int stock;

    @Version  // ← 버전 필드 추가
    private Long version;
}

 

-- UPDATE 시 version 체크
UPDATE product
SET stock = 0, version = 2
WHERE id = 1 AND version = 1

-- Thread B가 같은 version으로 UPDATE 시도하면 0 rows affected
-- → OptimisticLockException 발생

 

// 재시도 로직 필요
@Service
public class StockService {

    @Retryable(value = OptimisticLockException.class, maxAttempts = 3)
    @Transactional
    public void decrease(Long productId) {
        Product product = productRepository.findById(productId).orElseThrow();
        if (product.getStock() <= 0) {
            throw new RuntimeException("재고 없음");
        }
        product.setStock(product.getStock() - 1);
    }
}

 

낙관적 락:
  ✅ 충돌이 적을 때 성능 좋음 (락 안 잡으니까)
  ❌ 충돌 많으면 재시도 비용
  → 사용 사례: 게시글 수정, 프로필 수정

 

해결법 2: 비관적 락 (@Lock)

 

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

 

-- 실행되는 SQL
SELECT * FROM product WHERE id = 1 FOR UPDATE
-- → 다른 트랜잭션이 이 행을 읽거나 수정할 수 없음 (대기)

 

@Transactional
public void decrease(Long productId) {
    // 비관적 락으로 조회 → 다른 스레드는 대기
    Product product = productRepository.findByIdForUpdate(productId)
        .orElseThrow();

    if (product.getStock() <= 0) {
        throw new RuntimeException("재고 없음");
    }
    product.setStock(product.getStock() - 1);
    // 트랜잭션 종료 시 락 해제
}

 

비관적 락:
  ✅ 충돌이 많아도 확실한 동기화
  ❌ 대기 시간 발생 (성능 저하)
  ❌ 데드락 가능성
  → 사용 사례: 재고 차감, 포인트 사용, 좌석 예약

 

정리:
  읽기 많고 충돌 적음 → 낙관적 락 (@Version)
  쓰기 많고 충돌 많음 → 비관적 락 (@Lock)
  초고성능 필요       → Redis 분산 락 (별도 인프라)

 

 


 

6. 스프링 프로필 실수

 

증상

 

application-dev.yml 만들었는데 적용이 안 됨.
로컬에서 dev 설정으로 돌리고 싶은데 prod 설정이 적용됨.
@Profile("dev") 붙인 Bean이 운영에서 안 뜸.

 

원인 1: active profile 설정 안 함

 

# application.yml — 기본 설정
server:
  port: 8080

# application-dev.yml — dev 환경
server:
  port: 8081
  # 이 파일이 있어도 active profile 설정 안 하면 적용 안 됨!

 

# 해결: application.yml에 active profile 설정
spring:
  profiles:
    active: dev  # ← 이게 있어야 application-dev.yml이 로딩됨

 

# 또는 실행 시 지정
java -jar app.jar --spring.profiles.active=dev

# 또는 환경변수
SPRING_PROFILES_ACTIVE=dev java -jar app.jar

 

원인 2: @Profile Bean이 안 뜸

 

@Configuration
@Profile("dev")  // dev 프로필에서만 등록
public class DevConfig {

    @Bean
    public DataSource dataSource() {
        // H2 인메모리 DB
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .build();
    }
}

@Configuration
@Profile("prod")  // prod 프로필에서만 등록
public class ProdConfig {

    @Bean
    public DataSource dataSource() {
        // 실제 DB
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://prod-server:3306/mydb");
        return ds;
    }
}

 

active profile이 없으면:
  → @Profile("dev")도 안 뜸
  → @Profile("prod")도 안 뜸
  → DataSource Bean 없음 → 앱 시작 실패

active = dev 이면:
  → DevConfig만 뜸 → H2 사용

active = prod 이면:
  → ProdConfig만 뜸 → MySQL 사용

 

원인 3: yml 파일명 오타

 

application-dev.yml    ← 올바름
application_dev.yml    ← 틀림! (언더스코어)
application-Dev.yml    ← 틀림! (대문자)
aplication-dev.yml     ← 틀림! (오타)

 

실무 팁

 

# application.yml — 공통 설정 + 기본 프로필
spring:
  profiles:
    active: local  # 기본값: local

server:
  port: 8080

# application-local.yml — 로컬 개발
spring:
  datasource:
    url: jdbc:h2:mem:testdb

# application-dev.yml — 개발 서버
spring:
  datasource:
    url: jdbc:mysql://dev-server:3306/mydb

# application-prod.yml — 운영 서버
spring:
  datasource:
    url: jdbc:mysql://prod-server:3306/mydb

 

# 운영 배포 시
java -jar app.jar --spring.profiles.active=prod

# 여러 프로필 동시 적용
java -jar app.jar --spring.profiles.active=prod,monitoring

 

 


 

7. 한눈에 정리

 

┌─────────────────────────┬────────────────────────────────────┐
│ 버그                    │ 핵심 해결                          │
├─────────────────────────┼────────────────────────────────────┤
│ 1. 순환 참조            │ 단방향 의존, 서비스 분리           │
│ 2. Bean 등록 안 됨      │ @Component 확인, 스캔 범위 확인    │
│ 3. equals/hashCode      │ instanceof + id 비교, 고정 hash   │
│ 4. LocalDateTime        │ JavaTimeModule + timestamps=false  │
│ 5. 동시성 문제          │ 낙관적/비관적 락                   │
│ 6. 프로필 실수          │ spring.profiles.active 설정        │
└─────────────────────────┴────────────────────────────────────┘

 

 


 

8. 마무리

 

이 버그들의 공통점이 있다.

 

"동작 원리를 모르면 에러 메시지만 보고 헤맨다."

 

순환 참조     → Spring IoC 컨테이너의 Bean 생성 순서
Bean 등록     → Component Scan 범위와 프록시 메커니즘
equals        → JPA 프록시와 Object의 기본 동작
직렬화        → Jackson의 타입 처리 방식
동시성        → DB 트랜잭션 격리 수준과 락
프로필        → Spring Boot 설정 파일 로딩 순서

 

에러가 나면 "어떻게 고치지?"보다 "왜 이렇게 되지?"를 먼저 생각하자.

원리를 알면 비슷한 버그를 미리 예방할 수 있다.

 

 


 

이전 시리즈:

  • 1편: N+1 문제 총정리
  • 2편: @Transactional의 함정
  • 3편: JPA 더티 체킹
  • 4편: Entity를 API 응답으로 직접 반환하면 생기는 일

 

 


Spring, 순환참조, Circular Dependency, Bean, equals, hashCode, LocalDateTime, Jackson, 동시성, 낙관적락, 비관적락, Version, Lock, Profile, 면접, Spring Boot, JPA, 실무

반응형
반응형

Entity를 API 응답으로 직접 반환하면 생기는 일

 

Controller에서 Entity를 그대로 반환한 적 있는가?

 

@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
    return memberRepository.findById(id).orElseThrow();
}

 

"잘 되는데? DTO 만들기 귀찮은데 이대로 쓰면 안 돼?"

 

안 된다. 5가지 이유로 반드시 터진다.

 

 


 

1. 택배 상자로 이해하기

 

Entity = 회사 내부 서류 원본
DTO = 외부 전달용 복사본

Entity 직접 반환 = 내부 서류 원본을 고객에게 보내는 것
  → 주민번호가 적혀 있음 (민감 정보 노출)
  → 서류 양식 바꾸면 고객이 읽을 수 없음 (API 스펙 깨짐)
  → 서류에 다른 서류가 첨부돼 있고, 그 서류에 또 첨부가... (무한 재귀)

DTO 반환 = 필요한 내용만 복사해서 전달
  → 이름, 이메일만 적어서 보냄
  → 원본 양식이 바뀌어도 복사본은 그대로
  → 안전

 

 


 

2. 문제 1 — 민감 정보 노출

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;
    private String password;      // ← 비밀번호!
    private String socialNumber;  // ← 주민번호!
    private int failCount;        // ← 로그인 실패 횟수
}

 

@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
    return memberRepository.findById(id).orElseThrow();
}

 

// API 응답
{
  "id": 1,
  "name": "홍길동",
  "email": "hong@example.com",
  "password": "$2a$10$xyz...",     // 해시값이라도 노출됨
  "socialNumber": "900101-1234567", // 주민번호 그대로 노출!
  "failCount": 3                    // 내부 정보 노출
}

 

"@JsonIgnore 붙이면 되잖아?"

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;

    @JsonIgnore
    private String password;

    @JsonIgnore
    private String socialNumber;

    @JsonIgnore
    private int failCount;
}

 

되긴 된다. 근데 Entity가 Jackson(JSON 직렬화)에 종속된다. 새 필드를 추가할 때마다 "이거 노출해도 되나?" 확인해야 한다. 까먹으면 민감 정보가 그대로 나간다.

 

 


 

3. 문제 2 — 양방향 관계 무한 재귀

 

이게 가장 많이 터지는 문제다.

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members;
}

 

@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
    return memberRepository.findById(id).orElseThrow();
}

 

Jackson이 Member를 JSON으로 변환할 때:

Member → team 필드 직렬화
  → Team → members 필드 직렬화
    → Member → team 필드 직렬화
      → Team → members 필드 직렬화
        → Member → team 필드 직렬화
          → ...
            → StackOverflowError!

 

에러 로그:
com.fasterxml.jackson.databind.JsonMappingException:
  Infinite recursion (StackOverflowError)
    at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(...)
    at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(...)
    ...

 

"@JsonIgnore나 @JsonManagedReference 붙이면 되잖아?"

 

@Entity
public class Team {
    @JsonManagedReference
    @OneToMany(mappedBy = "team")
    private List<Member> members;
}

@Entity
public class Member {
    @JsonBackReference
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}

 

해결은 되지만, Entity에 JSON 관련 어노테이션이 쌓인다. Entity는 DB 매핑용이지, API 응답용이 아니다.

 

 


 

4. 문제 3 — Entity 변경 = API 스펙 변경

 

// 처음 설계
@Entity
public class Member {
    private String name;     // API 응답: { "name": "홍길동" }
}

// 3개월 후 요구사항 변경: 이름을 성/이름으로 분리
@Entity
public class Member {
    private String firstName;  // name 삭제!
    private String lastName;   // 새 필드!
}

 

// 기존 API 응답 (클라이언트가 쓰고 있던 것)
{ "name": "홍길동" }

// 변경 후 API 응답 (클라이언트 깨짐!)
{ "firstName": "길동", "lastName": "홍" }

 

Entity를 직접 반환하면 DB 스키마 변경이 곧 API 스펙 변경이 된다.

프론트엔드, 모바일 앱, 외부 연동 — 전부 깨진다.

 

DTO를 쓰면 Entity가 바뀌어도 API 스펙은 유지할 수 있다.

 

// Entity는 바뀌었지만
@Entity
public class Member {
    private String firstName;
    private String lastName;
}

// DTO는 기존 스펙 유지
public class MemberResponse {
    private String name;  // firstName + lastName 합쳐서 반환

    public MemberResponse(Member member) {
        this.name = member.getLastName() + member.getFirstName();
    }
}

 

 


 

5. 문제 4 — @JsonIgnore 떡칠

 

Entity에 @JsonIgnore가 3~4개 붙기 시작하면 이미 잘못된 거다.

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;

    @JsonIgnore
    private String password;

    @JsonIgnore
    private String socialNumber;

    @JsonIgnore
    private int failCount;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders;

    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDateTime createdAt;
}

 

문제점:
  1. Entity가 JSON 직렬화에 종속됨
  2. Entity의 역할이 불명확해짐 (DB 매핑? API 응답?)
  3. API마다 숨겨야 할 필드가 다를 수 있음
     → 목록 API: name, email만
     → 상세 API: name, email, createdAt
     → 관리자 API: 전부 다
  4. @JsonIgnore 하나로는 이런 케이스별 대응 불가

 

 


 

6. 문제 5 — LazyInitializationException

 

@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
    Member member = memberService.findById(id);
    return member;  // Controller에서 반환 → JSON 변환 시도
}

 

@Service
@Transactional(readOnly = true)
public class MemberService {
    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow();
        // team은 LAZY → 프록시 상태로 반환
    }
    // 여기서 트랜잭션 종료 → 영속성 컨텍스트 닫힘
}

 

Controller에서 JSON 변환 시:
  Jackson이 member.getTeam()에 접근
  → LAZY 프록시 초기화 시도
  → 영속성 컨텍스트 이미 닫힘
  → LazyInitializationException!

org.hibernate.LazyInitializationException:
  could not initialize proxy - no Session

 

"OSIV(Open Session In View) 켜면 되잖아?"

 

spring:
  jpa:
    open-in-view: true  # 기본값이 true

 

OSIV를 켜면 Controller까지 영속성 컨텍스트가 살아있어서 LazyInitializationException은 안 터진다. 근데 그러면 Controller에서 LAZY 로딩이 발생 → 예측 불가능한 쿼리가 나감 → 성능 문제.

 

OSIV = true (기본값):
  ✅ LazyInitializationException 안 터짐
  ❌ Controller에서 쿼리 나감 → 성능 예측 불가
  ❌ DB 커넥션을 오래 잡고 있음

OSIV = false (권장):
  ✅ 트랜잭션 범위 명확
  ✅ DB 커넥션 빨리 반환
  ❌ LazyInitializationException 주의 필요
  → 해결: DTO로 변환해서 반환

 

 


 

7. 해결 — Entity를 DTO로 변환

 

방법 1: 수동 변환

 

// Response DTO
public class MemberResponse {
    private Long id;
    private String name;
    private String email;
    private String teamName;

    public MemberResponse(Member member) {
        this.id = member.getId();
        this.name = member.getName();
        this.email = member.getEmail();
        this.teamName = member.getTeam().getName();
    }
}

 

// Controller
@GetMapping("/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
    Member member = memberService.findById(id);
    return new MemberResponse(member);
}

// Service — 트랜잭션 안에서 DTO 변환 (LAZY 로딩 안전)
@Service
@Transactional(readOnly = true)
public class MemberService {
    public MemberResponse findById(Long id) {
        Member member = memberRepository.findById(id).orElseThrow();
        return new MemberResponse(member);  // 여기서 team.getName() 호출 → 안전
    }
}

 

방법 2: Java Record (Java 16+)

 

public record MemberResponse(
    Long id,
    String name,
    String email,
    String teamName
) {
    public static MemberResponse from(Member member) {
        return new MemberResponse(
            member.getId(),
            member.getName(),
            member.getEmail(),
            member.getTeam().getName()
        );
    }
}

 

// 사용
MemberResponse response = MemberResponse.from(member);

 

방법 3: MapStruct (자동 매핑)

 

@Mapper(componentModel = "spring")
public interface MemberMapper {

    @Mapping(source = "team.name", target = "teamName")
    MemberResponse toResponse(Member member);
}

 

// 사용
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberMapper memberMapper;

    public MemberResponse findById(Long id) {
        Member member = memberRepository.findById(id).orElseThrow();
        return memberMapper.toResponse(member);
    }
}

 

수동 변환:   간단, 완전 제어, 매핑 코드 많아짐
Record:      간결, 불변, Java 16+ 필요
MapStruct:   자동 매핑, 컴파일 타임 검증, 설정 필요

 

 


 

8. 요청도 DTO — Entity를 RequestBody로 쓰지 말 것

 

// 나쁜 예: Entity를 Request로 사용
@PostMapping("/members")
public void create(@RequestBody Member member) {
    memberRepository.save(member);
    // 클라이언트가 id, role, status 같은 필드도 보내면?
    // → 의도치 않은 값이 저장될 수 있음
}

 

// 좋은 예: Request DTO 사용
public class CreateMemberRequest {
    @NotBlank
    private String name;

    @Email
    private String email;

    @Size(min = 8)
    private String password;

    public Member toEntity() {
        return Member.builder()
            .name(name)
            .email(email)
            .password(passwordEncoder.encode(password))
            .role(Role.USER)           // 서버에서 설정
            .status(Status.ACTIVE)     // 서버에서 설정
            .build();
    }
}

 

@PostMapping("/members")
public MemberResponse create(@RequestBody @Valid CreateMemberRequest request) {
    Member member = request.toEntity();
    memberRepository.save(member);
    return new MemberResponse(member);
}

 

 


 

9. 계층별 정리

 

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│ Controller  │ ←→  │   Service    │ ←→  │ Repository   │
│             │     │              │     │              │
│ Request DTO │     │   Entity     │     │   Entity     │
│ Response DTO│     │              │     │ (Query DTO)  │
└─────────────┘     └──────────────┘     └──────────────┘

Controller:
  - Request DTO 받기 (검증 포함)
  - Response DTO 반환

Service:
  - Entity로 비즈니스 로직 처리
  - DTO ↔ Entity 변환

Repository:
  - Entity 조회/저장
  - 성능 필요 시 Query DTO 직접 조회

 

 


 

10. "Entity에 @Setter 열면 안 되는 이유"

 

같은 맥락이다.

 

// 나쁜 예
@Entity
@Getter @Setter  // Setter 전체 오픈
public class Member {
    private String name;
    private String email;
    private Role role;
    private Status status;
}

// 아무 데서나 이렇게 할 수 있다
member.setRole(Role.ADMIN);     // 누가 어디서든 관리자로 변경 가능
member.setStatus(Status.DELETED); // 누가 어디서든 삭제 가능

 

// 좋은 예
@Entity
@Getter  // Setter 없음
public class Member {
    private String name;
    private String email;
    private Role role;
    private Status status;

    // 의미 있는 메서드로 상태 변경
    public void changeName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다");
        }
        this.name = name;
    }

    public void promote() {
        this.role = Role.ADMIN;
    }

    public void deactivate() {
        this.status = Status.INACTIVE;
    }
}

 

@Setter 전체 오픈:
  ❌ 누가, 어디서, 왜 바꿨는지 추적 불가
  ❌ 유효성 검증 없이 아무 값이나 세팅 가능
  ❌ Entity의 일관성 보장 불가

의미 있는 메서드:
  ✅ 변경 의도가 명확 (changeName, promote, deactivate)
  ✅ 유효성 검증 가능
  ✅ 비즈니스 로직 캡슐화

 

 


 

11. 정리

 

Entity 직접 반환의 문제 5가지:
  1. 민감 정보 노출 (password, socialNumber)
  2. 양방향 관계 → 무한 재귀 (StackOverflow)
  3. Entity 변경 → API 스펙 변경 (클라이언트 깨짐)
  4. @JsonIgnore 떡칠 → Entity가 API에 종속
  5. LazyInitializationException

해결:
  ✅ Entity → Response DTO 변환
  ✅ RequestBody → Request DTO 사용
  ✅ Entity에 @Setter 대신 의미 있는 메서드
  ✅ Service 레이어에서 DTO 변환 (트랜잭션 안에서)

 

핵심 한 줄

 

Entity는 DB와의 계약이고, DTO는 클라이언트와의 계약이다. 두 계약을 하나로 합치면 양쪽 다 깨진다.

 

 


 

다음 편 예고: 실무에서 자주 터지는 Spring 버그 모음 — 면접에도 나오는 것들

순환 참조, equals/hashCode 미구현, LocalDateTime 직렬화, 동시성 문제, 스프링 프로필 실수... 실무에서 한 번씩은 꼭 만나는 버그들을 정리한다. 면접에서도 단골 주제다.

 

 


Entity, DTO, API 응답, Jackson, JsonIgnore, 무한재귀, StackOverflow, LazyInitializationException, OSIV, MapStruct, Record, Setter, 캡슐화, Spring, JPA

반응형
반응형

JPA 더티 체킹 — save() 안 해도 되는데 왜? 그리고 언제 안 될까?

 

JPA를 처음 쓸 때 가장 놀라는 순간이 있다.

 

"어? save() 안 했는데 UPDATE가 나갔어?"

 

이게 더티 체킹(Dirty Checking)이다.

편하지만, 원리를 모르면 왜 되는지도 왜 안 되는지도 모른다.

 

 


 

1. 메모장으로 이해하기

 

JPA의 영속성 컨텍스트 = 메모장

1. DB에서 회원을 꺼내옴
   → 메모장에 "이름: 홍길동" 이라고 적어둠 (스냅샷)

2. 코드에서 이름을 "김철수"로 바꿈
   → 메모장에 적힌 건 여전히 "홍길동"

3. 트랜잭션 끝날 때
   → 메모장 확인: "홍길동" ≠ "김철수" → 달라졌네!
   → UPDATE 쿼리 자동 실행

이게 더티 체킹이다.
"메모장(스냅샷)이랑 현재 상태를 비교해서, 다르면 자동 업데이트."

 

 


 

2. 코드로 보기

 

save() 없이 UPDATE가 되는 경우

 

@Service
public class MemberService {

    @Transactional
    public void updateName(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        // member는 영속 상태 (영속성 컨텍스트가 관리 중)

        member.setName(newName);
        // save() 안 함!
        // 그런데 UPDATE 나감!
    }
}

 

-- 트랜잭션 커밋 시점에 자동 실행됨
UPDATE member SET name = '김철수' WHERE id = 1

 

// save()를 호출해도 결과는 같다
member.setName(newName);
memberRepository.save(member);  // 이미 영속 상태면 save()는 의미 없음
// JPA가 어차피 더티 체킹으로 UPDATE 날림

 

영속 상태 엔티티는 set만 해도 UPDATE가 나간다. save()는 불필요하다.

 

 


 

3. 동작 원리 — 영속성 컨텍스트와 스냅샷

 

[영속성 컨텍스트 (1차 캐시)]

┌─────────────────────────────────────────────────┐
│ ID    │ Entity (현재 상태)  │ Snapshot (최초 상태) │
├───────┼────────────────────┼─────────────────────┤
│ 1     │ Member(김철수)      │ Member(홍길동)       │ ← 다르다!
│ 2     │ Member(이영희)      │ Member(이영희)       │ ← 같다
└───────┴────────────────────┴─────────────────────┘

트랜잭션 커밋 시점:
  ID=1: 현재 ≠ 스냅샷 → UPDATE 실행
  ID=2: 현재 = 스냅샷 → UPDATE 안 함

 

상세 흐름

 

@Transactional
public void updateName(Long id, String newName) {

    // 1. DB에서 조회 → 영속성 컨텍스트에 저장 + 스냅샷 생성
    Member member = memberRepository.findById(id).orElseThrow();
    // 1차 캐시: { entity: Member(홍길동), snapshot: Member(홍길동) }

    // 2. 엔티티 값 변경
    member.setName(newName);
    // 1차 캐시: { entity: Member(김철수), snapshot: Member(홍길동) }

    // 3. 트랜잭션 커밋 시점 (메서드 종료)
    // → flush() 호출
    // → 1차 캐시의 entity와 snapshot을 필드 단위로 비교
    // → name이 다르다 → UPDATE 쿼리 생성 & 실행
}

 

flush() 타이밍:
  1. 트랜잭션 커밋 직전 (자동)
  2. JPQL 쿼리 실행 직전 (자동)
  3. em.flush() 직접 호출 (수동)

 

 


 

4. 더티 체킹이 안 되는 경우

 

경우 1 — @Transactional이 없을 때

 

@Service
public class MemberService {

    // @Transactional 없음!
    public void updateName(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        // UPDATE 안 나감!
    }
}

 

왜? @Transactional이 없으면 영속성 컨텍스트의 생명주기가 findById() 호출 때 시작되고 즉시 끝난다. 반환된 member는 준영속(detached) 상태다.

 

영속(managed):  영속성 컨텍스트가 관리 중 → 더티 체킹 O
준영속(detached): 영속성 컨텍스트에서 분리됨 → 더티 체킹 X
비영속(transient): 아직 저장 안 됨 → 더티 체킹 X

 

경우 2 — detach로 분리했을 때

 

@Transactional
public void updateName(Long id, String newName) {
    Member member = memberRepository.findById(id).orElseThrow();

    entityManager.detach(member);  // 영속성 컨텍스트에서 분리!

    member.setName(newName);
    // UPDATE 안 나감! detach 됐으니까.
}

 

// clear()도 마찬가지
entityManager.clear();  // 영속성 컨텍스트 전체 초기화
member.setName(newName);
// UPDATE 안 나감!

 

경우 3 — 벌크 연산 후

 

이게 실무에서 가장 위험한 케이스다.

 

@Transactional
public void bulkUpdate() {
    // 1. 회원 조회 → 영속성 컨텍스트에 저장
    Member member = memberRepository.findById(1L).orElseThrow();
    System.out.println(member.getAge());  // 20

    // 2. 벌크 연산: DB에서 직접 UPDATE
    entityManager.createQuery(
        "UPDATE Member m SET m.age = m.age + 1"
    ).executeUpdate();

    // DB에는 age = 21
    // 영속성 컨텍스트에는 age = 20 (반영 안 됨!)

    // 3. 다시 조회해도 1차 캐시에서 가져옴
    Member member2 = memberRepository.findById(1L).orElseThrow();
    System.out.println(member2.getAge());  // 20 ← DB는 21인데!

    // DB와 영속성 컨텍스트 불일치!
}

 

벌크 연산의 문제:
  DB에 직접 쿼리를 날림 → 영속성 컨텍스트를 건너뜀
  → DB와 1차 캐시가 다른 상태
  → 이후 조회가 틀린 값을 반환

 

해결: 벌크 연산 후 em.clear()

 

@Transactional
public void bulkUpdate() {
    Member member = memberRepository.findById(1L).orElseThrow();
    System.out.println(member.getAge());  // 20

    entityManager.createQuery(
        "UPDATE Member m SET m.age = m.age + 1"
    ).executeUpdate();

    entityManager.flush();  // 아직 안 보낸 변경사항 보내기
    entityManager.clear();  // 1차 캐시 초기화!

    // 이제 DB에서 새로 가져옴
    Member member2 = memberRepository.findById(1L).orElseThrow();
    System.out.println(member2.getAge());  // 21 ← 정확!
}

 

Spring Data JPA에서는 더 간단하게 할 수 있다.

 

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Modifying(clearAutomatically = true)  // 자동 clear
    @Query("UPDATE Member m SET m.age = m.age + 1")
    int bulkAgePlusOne();
}

 

 


 

5. merge() vs 더티 체킹

 

초보가 자주 헷갈리는 부분이다.

 

// 더티 체킹: 영속 상태에서 set → 자동 UPDATE
@Transactional
public void updateByDirtyChecking(Long id, String name) {
    Member member = memberRepository.findById(id).orElseThrow();  // 영속
    member.setName(name);  // 변경 감지 → UPDATE
}

// merge(): 준영속 객체를 다시 영속화
@Transactional
public void updateByMerge(MemberDto dto) {
    Member detachedMember = new Member();
    detachedMember.setId(dto.getId());
    detachedMember.setName(dto.getName());
    // detachedMember는 비영속/준영속 상태

    Member mergedMember = entityManager.merge(detachedMember);
    // mergedMember = 영속 상태의 새 객체
    // detachedMember는 여전히 준영속!
}

 

merge()의 함정

 

@Transactional
public void mergeTrap() {
    Member detached = new Member();
    detached.setId(1L);
    detached.setName("새이름");

    Member merged = entityManager.merge(detached);

    detached.setName("또다른이름");
    // ← 이건 반영 안 됨! detached는 여전히 준영속!

    merged.setName("또다른이름");
    // ← 이건 반영됨! merged가 영속 상태!
}

 

merge()의 동작:
  1. 1차 캐시에서 id로 엔티티 찾기
  2. 없으면 DB에서 조회
  3. 찾은 엔티티에 전달받은 값을 복사
  4. 찾은 엔티티(영속)를 반환

주의: 원본 객체가 영속이 되는 게 아니라, 새 객체가 반환됨!

 

실무에서는 merge()보다 더티 체킹이 안전하다. 가능하면 merge() 대신 "조회 → 값 변경" 패턴을 쓰자.

 

 


 

6. save()가 필요한 경우 vs 불필요한 경우

 

// save() 불필요: 이미 영속 상태
@Transactional
public void update(Long id, String name) {
    Member member = memberRepository.findById(id).orElseThrow();  // 영속
    member.setName(name);
    // memberRepository.save(member);  ← 불필요! 더티 체킹이 해줌
}

// save() 필요: 새 엔티티 저장
@Transactional
public void create(String name) {
    Member member = new Member();  // 비영속
    member.setName(name);
    memberRepository.save(member);  // ← 필요! persist() 호출
}

// save()가 헷갈리는 경우: id가 있는 새 객체
@Transactional
public void upsert(MemberDto dto) {
    Member member = new Member();
    member.setId(dto.getId());     // id 세팅
    member.setName(dto.getName());
    memberRepository.save(member);
    // id가 있으면 → merge() 호출 (기존 데이터 덮어쓰기)
    // id가 없으면 → persist() 호출 (새로 저장)
}

 

Spring Data JPA의 save() 내부:

if (entity.isNew()) {
    em.persist(entity);   // INSERT
} else {
    em.merge(entity);     // SELECT + UPDATE
}

isNew() 판단: @Id 값이 null이면 new, 아니면 기존

 

 


 

7. 성능 관점

 

더티 체킹은 편하지만, 모든 상황에서 최선은 아니다.

 

// 더티 체킹: 한 건씩 UPDATE
@Transactional
public void updateAllAges() {
    List<Member> members = memberRepository.findAll();  // 1000건
    for (Member member : members) {
        member.setAge(member.getAge() + 1);
    }
    // 커밋 시 UPDATE 1000번 실행!
}

// 벌크 연산: 한 번에 UPDATE
@Transactional
public void bulkUpdateAllAges() {
    memberRepository.bulkAgePlusOne();  // UPDATE 1번!
    entityManager.clear();
}

 

더티 체킹:
  ✅ 코드 간결, 엔티티 단위 로직 처리
  ❌ 대량 데이터 시 느림 (건건이 UPDATE)

벌크 연산:
  ✅ 대량 데이터 빠름 (한 번에 UPDATE)
  ❌ 영속성 컨텍스트 불일치 주의 (clear 필수)

 

1~10건: 더티 체킹, 100건 이상: 벌크 연산을 고려하자.

 

 


 

8. 정리

 

더티 체킹이란:
  → @Transactional 안에서 영속 엔티티의 필드를 바꾸면
  → 트랜잭션 커밋 시 스냅샷과 비교
  → 다르면 UPDATE 자동 실행
  → save() 불필요

더티 체킹이 안 되는 경우:
  1. @Transactional 없음 → 준영속 상태
  2. detach/clear로 분리 → 관리 안 됨
  3. 벌크 연산 후 → 영속성 컨텍스트 불일치

실무 규칙:
  1. 수정은 "조회 → set" 패턴 (merge 지양)
  2. 벌크 연산 후 em.clear() 또는 @Modifying(clearAutomatically = true)
  3. 대량 데이터는 벌크 연산 사용
  4. save()는 새 엔티티에만 (기존 엔티티는 더티 체킹)

 

 


 

다음 편 예고: Entity를 API 응답으로 직접 반환하면 생기는 일

"귀찮으니까 Entity 그대로 반환하면 안 돼?" 안 된다. 무한 재귀, 비밀번호 노출, LazyInitializationException... Entity를 절대 API 응답으로 쓰면 안 되는 5가지 이유와, 올바른 DTO 패턴을 정리한다.

 

 


JPA, 더티체킹, Dirty Checking, 영속성 컨텍스트, 스냅샷, flush, merge, detach, 벌크연산, Spring Data JPA, Hibernate, save, persist, 변경감지

반응형
반응형

@Transactional의 함정 — 붙였는데 왜 안 돼?

 

@Transactional은 Spring에서 가장 많이 쓰는 어노테이션 중 하나다.

 

"트랜잭션 필요하면 @Transactional 붙이면 되지."

 

맞는데, 붙여도 안 되는 경우가 5가지나 있다.

시니어도 가끔 실수하고, 면접에서도 자주 나온다.

 

 


 

1. 은행으로 이해하기

 

@Transactional = 은행 창구 번호표

번호표를 받으면:
  → 모든 업무가 한 묶음으로 처리됨
  → 중간에 실패하면 전부 취소 (롤백)
  → 끝나면 한 번에 반영 (커밋)

근데 번호표를 받아도 안 되는 경우가 있다:
  1. 창구 뒤에서 몰래 처리 (private)
  2. 같은 직원이 자기한테 번호표 넘김 (self-invocation)
  3. 에러를 삼켜버림 (try-catch)
  4. "이 에러는 취소 대상 아닙니다" (checked exception)
  5. "읽기 전용인데 수정하려고요?" (readOnly=true)

 

 


 

2. 함정 1 — private 메서드에 붙이면 안 된다

 

@Service
public class MemberService {

    @Transactional  // ← 이거 안 먹힘!
    private void updateMember(Long id, String name) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(name);
        // 트랜잭션 없이 실행됨 → 더티 체킹 안 됨
    }
}

 

왜 안 되는가?

 

Spring의 @Transactional은 프록시(Proxy) 기반으로 동작한다.

 

외부 호출 → [프록시] → [실제 객체]
             ↓
         트랜잭션 시작
         실제 메서드 호출
         트랜잭션 커밋/롤백

 

프록시가 메서드를 가로채서 트랜잭션을 감싸는 구조다.

근데 private 메서드는 프록시가 오버라이드할 수 없다.

 

// Spring이 내부적으로 만드는 프록시 (개념 코드)
public class MemberService$$Proxy extends MemberService {

    @Override
    public void updateMember(Long id, String name) {  // public만 오버라이드 가능
        // 트랜잭션 시작
        super.updateMember(id, name);
        // 트랜잭션 커밋
    }

    // private은 오버라이드 불가 → 트랜잭션 적용 불가
}

 

해결

 

@Transactional  // public으로 바꾸면 된다
public void updateMember(Long id, String name) {
    Member member = memberRepository.findById(id).orElseThrow();
    member.setName(name);
}

 

규칙: @Transactional은 반드시 public 메서드에만 붙인다.

 

 


 

3. 함정 2 — 같은 클래스 내부 호출 (self-invocation)

 

이게 가장 많이 실수하는 경우다.

 

@Service
public class MemberService {

    public void register(MemberDto dto) {
        // 회원 저장
        saveMember(dto);

        // 환영 메일 발송
        sendWelcomeEmail(dto.getEmail());
    }

    @Transactional  // ← 이거 안 먹힘!
    public void saveMember(MemberDto dto) {
        Member member = new Member(dto.getName(), dto.getEmail());
        memberRepository.save(member);
    }
}

 

왜 안 되는가?

 

[외부] → register() 호출
  → 프록시를 거침? NO!
  → register()에 @Transactional 없으니 그냥 통과
  → register() 안에서 saveMember() 호출
  → 이건 this.saveMember() = 실제 객체의 메서드 직접 호출
  → 프록시를 안 거침 → @Transactional 무시됨

 

// 이렇게 동작하는 거다
public class MemberService$$Proxy extends MemberService {

    @Override
    public void register(MemberDto dto) {
        // register에는 @Transactional 없으니 그냥 호출
        super.register(dto);
        // super.register() 안에서 saveMember()를 this로 호출
        // → 프록시를 안 거침!
    }

    @Override
    public void saveMember(MemberDto dto) {
        // 트랜잭션 시작
        super.saveMember(dto);
        // 트랜잭션 커밋
        // → 이건 외부에서 직접 호출해야 작동
    }
}

 

해결법 1 — 클래스 분리 (권장)

 

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberWriter memberWriter;

    public void register(MemberDto dto) {
        memberWriter.saveMember(dto);  // 외부 Bean 호출 → 프록시 거침
        sendWelcomeEmail(dto.getEmail());
    }
}

@Service
public class MemberWriter {

    @Transactional  // ← 이제 작동함!
    public void saveMember(MemberDto dto) {
        Member member = new Member(dto.getName(), dto.getEmail());
        memberRepository.save(member);
    }
}

 

해결법 2 — 자기 자신 주입

 

@Service
public class MemberService {

    @Lazy
    @Autowired
    private MemberService self;  // 프록시 객체를 주입받음

    public void register(MemberDto dto) {
        self.saveMember(dto);  // self = 프록시 → @Transactional 작동
        sendWelcomeEmail(dto.getEmail());
    }

    @Transactional
    public void saveMember(MemberDto dto) {
        Member member = new Member(dto.getName(), dto.getEmail());
        memberRepository.save(member);
    }
}

 

실무에서는 클래스 분리가 더 깔끔하다.

 

 


 

4. 함정 3 — try-catch로 예외를 삼키면 롤백 안 된다

 

@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
    try {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        from.withdraw(amount);
        to.deposit(amount);

        // 여기서 에러 발생!
        externalApiClient.notifyTransfer(fromId, toId, amount);

    } catch (Exception e) {
        log.error("이체 알림 실패", e);
        // 예외를 삼켜버림 → 롤백 안 됨!
        // from에서 돈은 빠졌는데, 알림만 실패한 줄 알고 커밋됨
    }
}

 

왜 안 되는가?

 

@Transactional의 롤백 조건:
  → 메서드에서 예외가 던져져야 함 (throw)
  → catch로 잡아버리면 "정상 종료"로 판단
  → 커밋됨

 

// Spring 내부 동작 (개념 코드)
try {
    트랜잭션_시작();
    실제_메서드_호출();  // 예외가 여기서 나와야 함
    트랜잭션_커밋();     // 예외 없으면 커밋
} catch (Exception e) {
    트랜잭션_롤백();     // 예외 나오면 롤백
}
// → catch 안에서 삼키면 예외가 밖으로 안 나옴 → 커밋됨

 

해결

 

@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
    try {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        from.withdraw(amount);
        to.deposit(amount);

        externalApiClient.notifyTransfer(fromId, toId, amount);

    } catch (Exception e) {
        log.error("이체 실패", e);
        throw e;  // ← 다시 던져야 롤백됨!
    }
}

 

또는 알림은 트랜잭션 밖에서 처리한다.

 

@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
    Account from = accountRepository.findById(fromId).orElseThrow();
    Account to = accountRepository.findById(toId).orElseThrow();
    from.withdraw(amount);
    to.deposit(amount);
    // 여기까지만 트랜잭션
}

// 알림은 별도로
public void transferAndNotify(Long fromId, Long toId, int amount) {
    transferMoney(fromId, toId, amount);
    try {
        externalApiClient.notifyTransfer(fromId, toId, amount);
    } catch (Exception e) {
        log.error("알림 실패 (이체는 완료됨)", e);
    }
}

 

 


 

5. 함정 4 — checked exception은 롤백 안 된다

 

@Transactional
public void createOrder(OrderDto dto) throws IOException {
    Order order = new Order(dto.getProductId(), dto.getQuantity());
    orderRepository.save(order);

    // IOException = checked exception
    fileService.saveReceipt(order);  // IOException 발생!
    // → 롤백 안 됨! order는 DB에 저장됨!
}

 

왜 안 되는가?

 

Spring @Transactional 기본 롤백 규칙:
  RuntimeException (unchecked)  → 롤백 O
  Error                          → 롤백 O
  Exception (checked)           → 롤백 X  ← !!!!

Java 예외 구조:
  Throwable
  ├── Error (롤백 O)
  └── Exception
      ├── RuntimeException (롤백 O)
      │   ├── NullPointerException
      │   ├── IllegalArgumentException
      │   └── ...
      └── checked exceptions (롤백 X)
          ├── IOException
          ├── SQLException
          └── ...

 

왜 이런 설계인가? EJB 시절 관례를 따른 것이다. "checked exception은 비즈니스 예외이므로 복구 가능하다"는 가정인데, 실무에서는 대부분 틀린 가정이다.

 

해결

 

// 방법 1: rollbackFor 명시
@Transactional(rollbackFor = Exception.class)  // 모든 예외에 롤백
public void createOrder(OrderDto dto) throws IOException {
    Order order = new Order(dto.getProductId(), dto.getQuantity());
    orderRepository.save(order);
    fileService.saveReceipt(order);
}

// 방법 2: checked를 unchecked로 감싸기
@Transactional
public void createOrder(OrderDto dto) {
    try {
        Order order = new Order(dto.getProductId(), dto.getQuantity());
        orderRepository.save(order);
        fileService.saveReceipt(order);
    } catch (IOException e) {
        throw new RuntimeException("영수증 저장 실패", e);  // unchecked로 변환
    }
}

 

실무 팁: @Transactional(rollbackFor = Exception.class)를 습관적으로 쓰자.

 

 


 

6. 함정 5 — readOnly=true인데 수정하기

 

@Transactional(readOnly = true)
public void updateMemberName(Long id, String name) {
    Member member = memberRepository.findById(id).orElseThrow();
    member.setName(name);  // ← 변경 감지 안 됨! UPDATE 안 나감!
}

 

왜 안 되는가?

 

readOnly = true 설정 시:
  1. Hibernate: 스냅샷을 안 만듦 → 더티 체킹 비활성화
  2. JDBC: setReadOnly(true) → DB 드라이버 최적화
  3. 변경해도 UPDATE 쿼리 안 나감
  4. 에러도 안 남 → 조용히 무시됨 ← 위험!

 

// 이런 식으로 착각하기 쉽다
@Service
@Transactional(readOnly = true)  // 클래스 레벨에 readOnly
public class MemberService {

    public Member getMember(Long id) {
        return memberRepository.findById(id).orElseThrow();  // OK
    }

    // readOnly인데 수정하려고 함 → UPDATE 안 나감!
    public void updateMember(Long id, String name) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(name);
        // 에러 없이 그냥 무시됨... name 안 바뀜
    }
}

 

해결

 

@Service
@Transactional(readOnly = true)  // 기본: 읽기 전용
public class MemberService {

    public Member getMember(Long id) {
        return memberRepository.findById(id).orElseThrow();
    }

    @Transactional  // 수정 메서드는 readOnly 없이 오버라이드
    public void updateMember(Long id, String name) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(name);  // 이제 UPDATE 나감
    }
}

 

패턴: 클래스에 @Transactional(readOnly = true), 수정 메서드에만 @Transactional.

 

 


 

7. 보너스 — 트랜잭션 전파 (Propagation)

 

면접에서 자주 나오는 주제다.

 

@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        orderRepository.save(new Order(dto));
        paymentService.processPayment(dto);  // 다른 서비스 호출
    }
}

@Service
public class PaymentService {

    @Transactional  // 전파 속성에 따라 동작이 달라진다
    public void processPayment(OrderDto dto) {
        paymentRepository.save(new Payment(dto));
    }
}

 

REQUIRED (기본값)

 

@Transactional(propagation = Propagation.REQUIRED)  // 기본값
public void processPayment(OrderDto dto) { ... }

 

OrderService.createOrder() → 트랜잭션 A 시작
  └→ PaymentService.processPayment() → 트랜잭션 A에 참여 (같은 트랜잭션)

결과:
  - 어디서든 에러 → 전부 롤백 (Order + Payment)
  - 하나의 트랜잭션으로 묶임

 

REQUIRES_NEW

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(OrderDto dto) { ... }

 

OrderService.createOrder() → 트랜잭션 A 시작
  └→ PaymentService.processPayment() → 트랜잭션 A 일시 중단, 트랜잭션 B 새로 시작

결과:
  - Payment에서 에러 → 트랜잭션 B만 롤백 (Payment만)
  - Order는 트랜잭션 A이므로 별개로 판단
  - 단, Payment 에러가 createOrder()까지 올라가면 A도 롤백됨

 

"부모가 롤백되면 자식도 롤백되나?"

 

REQUIRED (같은 트랜잭션):
  부모 롤백 → 자식도 롤백 (O)
  자식 롤백 → 부모도 롤백 (O)
  → 한 몸이니까

REQUIRES_NEW (별도 트랜잭션):
  부모 롤백 → 자식은 이미 커밋됨 (X, 자식은 유지)
  자식 롤백 → 부모는 별개 (에러 전파 안 하면 부모 유지)

 

실무에서 REQUIRES_NEW를 쓰는 경우

 

// 로그는 무조건 남겨야 한다 — 비즈니스 로직이 실패해도
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        try {
            orderRepository.save(new Order(dto));
            // 에러 발생!
        } catch (Exception e) {
            auditService.saveLog("주문 실패: " + e.getMessage());  // 로그는 남겨야 함
            throw e;
        }
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 새 트랜잭션
    public void saveLog(String message) {
        auditRepository.save(new AuditLog(message));
        // 부모가 롤백돼도 이건 커밋됨
    }
}

 

 


 

8. 정리

 

@Transactional이 안 되는 5가지:

  1. private 메서드      → public으로
  2. 같은 클래스 내부 호출 → 클래스 분리
  3. try-catch 예외 삼킴  → throw 다시 던지기
  4. checked exception   → rollbackFor = Exception.class
  5. readOnly = true     → 수정 메서드에 @Transactional 별도 선언

전파 규칙:
  REQUIRED (기본):     같은 트랜잭션에 참여 → 한 몸
  REQUIRES_NEW:        새 트랜잭션 생성 → 별개

 

실무 팁

 

1. @Transactional은 Service 레이어에만 붙인다
   → Controller, Repository에는 X

2. 클래스에 @Transactional(readOnly = true), 수정 메서드에 @Transactional

3. rollbackFor = Exception.class 습관적으로

4. self-invocation 주의 — 같은 클래스 안에서 호출하면 안 먹힘

5. 트랜잭션 범위는 최소한으로 — 외부 API 호출은 트랜잭션 밖에서

 

 


 

다음 편 예고: JPA 더티 체킹 — save() 안 해도 되는데 왜? 그리고 언제 안 될까?

@Transactional 안에서 엔티티 필드를 바꾸면 save() 없이도 UPDATE가 나간다. 초보가 가장 놀라는 JPA의 마법, 그 원리와 함정을 파헤친다.

 

 


Transactional, Spring, 트랜잭션, 프록시, self-invocation, 롤백, checked exception, propagation, REQUIRED, REQUIRES_NEW, readOnly, AOP, 면접

반응형
반응형

N+1 문제 총정리 — EAGER만 문제가 아니다

 

이전 글에서 EAGER의 N+1 문제를 다뤘다.

 

"그러면 LAZY로 바꾸면 되는 거 아니야?"

 

아니다. LAZY에서도 N+1은 터진다.

 

오히려 LAZY의 N+1이 더 위험하다. 코드에서 안 보이니까.

 

 


 

1. 택배로 이해하기

 

회원 100명의 팀 이름을 출력해야 한다.

EAGER의 N+1:
  "회원 목록 주세요" → 회원 100명 도착
  → 근데 택배 기사가 각 회원의 팀 정보도 따로따로 배달
  → 택배차 101번 옴 (1번 + 100번)

LAZY에서 N+1 안 터지는 경우:
  "회원 목록 주세요" → 회원 100명 도착
  → 팀 정보? 안 열어봄 → 택배차 1번만 옴

LAZY에서 N+1 터지는 경우:
  "회원 목록 주세요" → 회원 100명 도착
  → 근데 각 회원의 팀 이름을 하나씩 열어봄
  → 열어볼 때마다 택배차가 옴
  → 택배차 101번 옴 (1번 + 100번)

 

LAZY는 "안 쓰면 안 가져온다"일 뿐, "쓰면 하나씩 가져온다."

 

 


 

2. EAGER에서의 N+1 (복습)

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;
}

 

List<Member> members = em.createQuery(
    "SELECT m FROM Member m", Member.class
).getResultList();

 

-- 1번: Member 전체 조회
SELECT * FROM member

-- N번: 각 Member의 Team 조회
SELECT * FROM team WHERE id = 1
SELECT * FROM team WHERE id = 2
SELECT * FROM team WHERE id = 3
...
SELECT * FROM team WHERE id = 100

 

JPQL은 SQL을 그대로 만들기 때문에, EAGER여도 JOIN을 자동으로 넣지 않는다.

Member를 가져온 뒤, "어? EAGER네?" 하고 Team을 하나씩 추가 조회한다.

 

 


 

3. LAZY에서도 N+1이 터지는 경우

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)  // LAZY로 바꿨다
    private Team team;
}

 

List<Member> members = em.createQuery(
    "SELECT m FROM Member m", Member.class
).getResultList();

// 여기까지는 쿼리 1번. 문제없다.

 

SELECT * FROM member
-- 끝. Team 쿼리 안 나감. LAZY니까.

 

근데 이렇게 하면?

 

// 각 회원의 팀 이름을 출력
for (Member member : members) {
    System.out.println(member.getTeam().getName());  // ← 여기!
}

 

-- for문이 돌면서 Team 쿼리가 N번 나감
SELECT * FROM team WHERE id = 1   -- 1번째 회원의 팀
SELECT * FROM team WHERE id = 2   -- 2번째 회원의 팀
SELECT * FROM team WHERE id = 3   -- 3번째 회원의 팀
...
SELECT * FROM team WHERE id = 100 -- 100번째 회원의 팀

 

LAZY인데 N+1이 터졌다.

 

EAGER N+1: 조회하는 순간 바로 터짐 (눈에 보임)
LAZY N+1:  for문에서 getter 호출할 때 터짐 (코드에서 안 보임)

 

LAZY의 N+1이 더 위험한 이유는, 쿼리 로그를 안 보면 모른다는 것이다.

코드만 보면 그냥 getter 호출인데, 내부에서는 SELECT가 N번 나가고 있다.

 

 


 

4. 해결법 1 — fetch join

 

가장 많이 쓰는 방법이다.

 

List<Member> members = em.createQuery(
    "SELECT m FROM Member m JOIN FETCH m.team", Member.class
).getResultList();

 

-- 실행되는 SQL: JOIN으로 한 번에 가져옴
SELECT m.*, t.*
FROM member m
INNER JOIN team t ON m.team_id = t.id

 

// 이제 for문 돌아도 추가 쿼리 안 나감
for (Member member : members) {
    System.out.println(member.getTeam().getName());  // 이미 가져옴
}

 

쿼리 1번으로 끝난다.

 

fetch join 주의점

 

// 1:N 컬렉션 fetch join 시 데이터 중복 가능
List<Team> teams = em.createQuery(
    "SELECT t FROM Team t JOIN FETCH t.members", Team.class
).getResultList();

// Team A에 Member가 3명이면 → Team A가 3번 나옴
// 해결: DISTINCT 사용
List<Team> teams = em.createQuery(
    "SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class
).getResultList();

 

// 컬렉션 fetch join은 페이징 불가
List<Team> teams = em.createQuery(
    "SELECT t FROM Team t JOIN FETCH t.members", Team.class
)
.setFirstResult(0)
.setMaxResults(10)  // ← 경고! 메모리에서 페이징함
.getResultList();

// Hibernate 경고:
// HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
// → 전체 데이터를 메모리에 올린 뒤 잘라냄 → OutOfMemoryError 위험

 

 


 

5. 해결법 2 — @EntityGraph

 

fetch join을 어노테이션으로 쓸 수 있다. Spring Data JPA에서 편하다.

 

public interface MemberRepository extends JpaRepository<Member, Long> {

    // 기본 findAll()은 Team을 안 가져옴 (LAZY)
    // @EntityGraph를 붙이면 fetch join과 같은 효과
    @EntityGraph(attributePaths = {"team"})
    @Override
    List<Member> findAll();
}

 

-- 실행되는 SQL
SELECT m.*, t.*
FROM member m
LEFT JOIN team t ON m.team_id = t.id

 

// 메서드 이름 쿼리에도 사용 가능
@EntityGraph(attributePaths = {"team"})
List<Member> findByName(String name);

// 여러 연관관계 동시에
@EntityGraph(attributePaths = {"team", "orders"})
List<Member> findAll();

 

장점: JPQL 안 써도 됨, 간결함

단점: 복잡한 조건이 필요하면 결국 JPQL fetch join을 써야 함

 

 


 

6. 해결법 3 — @BatchSize

 

fetch join이 안 되는 상황이 있다. 컬렉션 2개 이상 동시 fetch join은 불가능하다.

 

// 이렇게 하면 MultipleBagFetchException 터짐
List<Team> teams = em.createQuery(
    "SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects",
    Team.class
).getResultList();
// → org.hibernate.loader.MultipleBagFetchException:
//   cannot simultaneously fetch multiple bags

 

이때 @BatchSize를 쓴다.

 

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members;
}

 

List<Team> teams = em.createQuery(
    "SELECT t FROM Team t", Team.class
).getResultList();

// Team이 150개라면?
for (Team team : teams) {
    team.getMembers().size();  // 이 시점에 IN 쿼리 발생
}

 

-- @BatchSize 없으면: Team 150개 → Member 쿼리 150번
SELECT * FROM member WHERE team_id = 1
SELECT * FROM member WHERE team_id = 2
...
SELECT * FROM member WHERE team_id = 150

-- @BatchSize(size = 100)이면: IN 절로 묶어서 2번
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... , 100)   -- 100개
SELECT * FROM member WHERE team_id IN (101, 102, ... , 150)   -- 50개

 

쿼리 150번 → 쿼리 2번으로 줄어든다.

 

글로벌 설정 (권장)

 

엔티티마다 @BatchSize 붙이기 귀찮으면 글로벌로 설정한다.

 

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

 

이러면 모든 지연 로딩에 자동으로 IN 절이 적용된다.

실무에서 가장 먼저 해야 할 설정이다.

 

 


 

7. 해결법 4 — DTO 직접 조회

 

엔티티가 아니라 필요한 데이터만 뽑아오는 방법이다.

 

// DTO 정의
public class MemberTeamDto {
    private String memberName;
    private String teamName;

    public MemberTeamDto(String memberName, String teamName) {
        this.memberName = memberName;
        this.teamName = teamName;
    }
}

 

List<MemberTeamDto> result = em.createQuery(
    "SELECT new com.example.dto.MemberTeamDto(m.name, t.name) " +
    "FROM Member m JOIN m.team t", MemberTeamDto.class
).getResultList();

 

-- 실행되는 SQL: 필요한 컬럼만 가져옴
SELECT m.name, t.name
FROM member m
JOIN team t ON m.team_id = t.id

 

장점: 필요한 것만 가져오니까 성능 최고, N+1 원천 차단

단점: DTO 만들어야 하고, 패키지명까지 써야 해서 번거로움

 

 


 

8. 해결법 비교

 

┌──────────────────┬────────────┬──────────────┬──────────────┐
│ 방법             │ 쿼리 수    │ 편의성       │ 제한사항     │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ fetch join       │ 1번        │ ★★★★       │ 컬렉션 2개↑  │
│                  │            │              │ 동시 불가    │
│                  │            │              │ 페이징 주의  │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ @EntityGraph     │ 1번        │ ★★★★★     │ 복잡한 조건  │
│                  │            │              │ 처리 어려움  │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ @BatchSize       │ 1 + α번   │ ★★★★★     │ 완전한 1번은 │
│                  │ (매우 적음) │              │ 아님         │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ DTO 직접 조회    │ 1번        │ ★★★        │ DTO 작성     │
│                  │            │              │ 필요         │
└──────────────────┴────────────┴──────────────┴──────────────┘

 

 


 

9. 실무 권장 전략

 

1단계: 글로벌 BatchSize 설정
  → application.yml에 default_batch_fetch_size: 100
  → 이것만으로 대부분의 N+1이 사라짐

2단계: 핵심 API에 fetch join 적용
  → 자주 호출되는 API는 fetch join으로 쿼리 1번으로 줄이기

3단계: 성능이 중요한 곳은 DTO 직접 조회
  → 화면에 필요한 데이터만 뽑아오기

 

// 이 순서로 적용하면 된다

// 1. 먼저 글로벌 설정 (전체 적용)
spring.jpa.properties.hibernate.default_batch_fetch_size=100

// 2. 핫 API에 fetch join
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.status = :status")
List<Member> findActiveMembers(@Param("status") String status);

// 3. 대시보드 같은 곳은 DTO
@Query("SELECT new MemberSummaryDto(m.name, t.name, COUNT(o)) " +
       "FROM Member m JOIN m.team t LEFT JOIN m.orders o " +
       "GROUP BY m.name, t.name")
List<MemberSummaryDto> getMemberSummary();

 

 


 

10. 정리

 

N+1 문제:
  ❌ EAGER만의 문제가 아니다
  ❌ LAZY로 바꿔도 getter 호출하면 똑같이 터진다
  ✅ LAZY + fetch join 조합이 정답
  ✅ 글로벌 BatchSize 설정이 첫 번째 할 일
  ✅ 성능 중요한 곳은 DTO 직접 조회

 

핵심 한 줄

 

N+1은 "로딩 전략"의 문제가 아니라 "조회 전략"의 문제다.

 

EAGER든 LAZY든, 연관 데이터를 어떻게 가져올지 명시하지 않으면 N+1은 언제든 터진다.

 

 


 

다음 편 예고: @Transactional의 함정 — 붙였는데 왜 안 돼?

@Transactional을 붙이면 다 되는 줄 알았는데, 5가지 경우에 안 된다. private 메서드, self-invocation, checked exception... 하나씩 코드로 확인한다.

 

 


N+1, JPA, JPQL, fetch join, EntityGraph, BatchSize, DTO, Hibernate, Spring Data JPA, 지연로딩, 즉시로딩, 성능최적화, 쿼리최적화, ORM

반응형
반응형

Java NIO (New I/O)는 Java 1.4 버전에서 도입된 **비동기 I/O (Asynchronous I/O)**를 지원하는 강력한 I/O 라이브러리입니다. 특히, 대규모 네트워크 서버파일 시스템과의 I/O 처리가 중요한 애플리케이션에서 매우 유용합니다. Java NIO의 가장 큰 특징은 non-blocking I/O, 버퍼 (Buffer), 채널 (Channel), 셀렉터 (Selector) 등의 개념을 제공하여 높은 성능과 효율적인 자원 관리를 가능하게 만든다는 점입니다.

이번에는 Java NIO의 고급 사용법비동기 처리, 파일 처리, 서버 구현 등의 측면에서 심도 깊게 다뤄보겠습니다.


Java NIO 주요 개념

  1. Channel:
    • NIO에서 데이터의 입출력 작업을 수행하는 통로입니다.
    • FileChannel, SocketChannel, DatagramChannel, ServerSocketChannel 등이 있습니다.
    • NIO의 채널은 기본적으로 non-blocking 모드로 동작할 수 있어, 서버 애플리케이션에서 더 많은 클라이언트를 동시에 처리할 수 있습니다.
  2. Buffer:
    • 데이터는 Buffer 객체를 통해 읽기쓰기가 이루어집니다.
    • ByteBuffer, CharBuffer, IntBuffer 등 여러 종류가 있으며, 기본적인 버퍼링 기능을 제공합니다.
    • 버퍼는 데이터를 읽거나 쓸 때 position, limit, capacity 등을 통해 제어합니다.
  3. Selector:
    • 여러 채널을 감시하고, I/O 작업을 비동기적으로 처리할 수 있는 메커니즘입니다.
    • 여러 채널에서 입력/출력 이벤트가 발생했을 때 해당 이벤트를 **선택(select)**하여 처리합니다.
    • Selector는 non-blocking I/O 환경에서 매우 중요한 역할을 합니다.

Java NIO 고급 사용법 예시

1. NIO를 사용한 고성능 파일 I/O 처리

Java NIO의 FileChannel을 사용하면 파일을 비동기적으로 읽고 쓸 수 있습니다. **메모리 맵핑 (Memory-mapping)**을 활용하면 파일 데이터를 메모리처럼 직접 다룰 수 있어 성능을 극대화할 수 있습니다.

예시: FileChannel을 이용한 대용량 파일 읽기 및 쓰기

import java.io.*;
import java.nio.*;
import java.nio.channels.*;

public class FileChannelExample {
    public static void main(String[] args) throws IOException {
        // 큰 파일을 읽고 쓰는 예시
        File inputFile = new File("largeInputFile.txt");
        File outputFile = new File("largeOutputFile.txt");

        try (FileChannel inputChannel = new FileInputStream(inputFile).getChannel();
             FileChannel outputChannel = new FileOutputStream(outputFile).getChannel()) {

            // 메모리 맵핑을 이용한 파일 읽기
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            while (inputChannel.read(buffer) > 0) {
                buffer.flip();  // 버퍼를 읽기 모드로 설정
                outputChannel.write(buffer);
                buffer.clear();  // 버퍼를 다시 쓰기 모드로 설정
            }

            System.out.println("File copy completed.");
        }
    }
}

핵심 포인트:

  • FileChannel은 대용량 파일 처리에 매우 효율적입니다.
  • ByteBuffer를 사용하여 데이터를 버퍼링하고, I/O 성능을 최적화할 수 있습니다.

2. Non-blocking 서버 소켓 구현 (ServerSocketChannel과 Selector)

NIO의 ServerSocketChannelSelector를 활용하면, 비동기 방식으로 다수의 클라이언트 요청을 동시에 처리할 수 있습니다. Selector는 여러 채널을 동시에 감시할 수 있기 때문에, 하나의 스레드로 다수의 클라이언트 요청을 처리할 수 있습니다.

예시: Non-blocking 서버 소켓 구현

import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.util.*;

public class NioServer {
    public static void main(String[] args) throws IOException {
        // 서버 소켓 채널을 열고 설정
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);
        serverChannel.bind(new InetSocketAddress(8080));

        // 셀렉터 열기
        Selector selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            // 셀렉터에서 이벤트가 발생할 때까지 대기
            selector.select();

            // 셀렉터에 등록된 키를 순회하면서 이벤트 처리
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();

                if (key.isAcceptable()) {
                    // 클라이언트 연결 수락
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel client = server.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                    System.out.println("New client connected: " + client.getRemoteAddress());
                } else if (key.isReadable()) {
                    // 클라이언트로부터 데이터 읽기
                    SocketChannel client = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(256);
                    int bytesRead = client.read(buffer);
                    if (bytesRead == -1) {
                        client.close();
                    } else {
                        buffer.flip();
                        client.write(buffer);  // 데이터를 다시 클라이언트로 전송
                    }
                }
            }
        }
    }
}

핵심 포인트:

  • ServerSocketChannelSelector를 사용하여 non-blocking 서버를 구현합니다.
  • Selector는 여러 입력/출력 이벤트를 처리하며, 하나의 스레드로 여러 클라이언트를 동시에 처리할 수 있습니다.
  • 클라이언트 요청을 비동기적으로 처리하여 성능을 최적화합니다.

3. Java NIO를 활용한 비동기 네트워크 클라이언트

NIO를 사용하면 클라이언트에서도 non-blocking I/O 방식으로 네트워크 통신을 할 수 있습니다. 이를 통해 대기 시간 없이 다른 작업을 병렬로 처리할 수 있습니다.

예시: Non-blocking 클라이언트 구현

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.net.*;

public class NioClient {
    public static void main(String[] args) throws IOException {
        // 클라이언트 소켓 채널 열기
        SocketChannel clientChannel = SocketChannel.open();
        clientChannel.configureBlocking(false);
        clientChannel.connect(new InetSocketAddress("localhost", 8080));

        // 셀렉터 열기
        Selector selector = Selector.open();
        clientChannel.register(selector, SelectionKey.OP_CONNECT);

        while (true) {
            // 셀렉터에서 이벤트 대기
            selector.select();

            // 셀렉터의 키를 순회
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();

                if (key.isConnectable()) {
                    SocketChannel channel = (SocketChannel) key.channel();
                    if (channel.isConnectionPending()) {
                        channel.finishConnect();  // 연결 완료
                        System.out.println("Connected to server.");
                    }
                    channel.register(selector, SelectionKey.OP_WRITE);
                } else if (key.isWritable()) {
                    // 서버에 데이터 전송
                    SocketChannel channel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(256);
                    buffer.put("Hello, Server!".getBytes());
                    buffer.flip();
                    channel.write(buffer);
                    System.out.println("Message sent to server.");
                    channel.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 서버로부터 데이터 수신
                    SocketChannel channel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(256);
                    int bytesRead = channel.read(buffer);
                    if (bytesRead == -1) {
                        channel.close();
                    } else {
                        buffer.flip();
                        String response = new String(buffer.array(), 0, bytesRead);
                        System.out.println("Received from server: " + response);
                    }
                }
            }
        }
    }
}

핵심 포인트:

  • SocketChannel을 사용하여 non-blocking 방식으로 서버에 연결하고 데이터를 주고받습니다.
  • Selector를 사용하여 I/O 이벤트를 처리하며, 메인 스레드에서 다른 작업을 수행하면서도 클라이언트와 서버 간의 통신을 비동기적으로 처리합니다.

NIO를 활용한 고급 팁

  1. 메모리 맵핑 (Memory-mapped Files):
    • **MappedByteBuffer**를 사용하면 파일을 메모리에 직접 매핑하여 매우 빠른 파일 처리가 가능합니다. 이는 대용량 파일을 처리하는 애플리케이션에서 속도효율성을 크게 개선할 수 있습니다.
  2. Selector와 여러 채널을 동시에 관리:
    • 여러 네트워크 채널, 파일 채널을 동시에 처리해야 하는 경우, SelectorMultiplexing 기법을 활용하여 스레드 수를 최소화하고 시스템 성능을 최적화할 수 있습니다.
  3. 배치 작업 처리:
    • Batch I/O operations: 여러 개의 I/O 작업을 한 번에 처리하거나, 특정 시점에 한 번에 모든 데이터를 읽거나 쓸 수 있습니다. 이를 통해 I/O 성능을 최적화할 수 있습니다.

정리

Java NIO는 비동기 I/O, Selector, Channel, Buffer 등을 활용하여 고성능 서버대규모 I/O 처리를 할 수 있도록 해줍니다. 이를 통해 단일 스레드로도 많은 클라이언트를 처리할 수 있으며, 성능을 크게 개선할 수 있습니다. Java NIO는 대용량 데이터 처리고성능 네트워크 서버에서 매우 유용하게 사용됩니다.

반응형
반응형

writeObject와 readObject 메서드를 통한 커스텀 직렬화는 Java에서 객체를 직렬화하고 역직렬화할 때, 기본 직렬화 방식의 동작을 변경하거나 제어할 수 있게 해주는 강력한 기능입니다. 이를 통해 데이터의 직렬화 과정커스터마이즈할 수 있으며, 성능 최적화보안을 강화하거나 특정 필드를 제외하는 등의 작업을 할 수 있습니다.

직렬화와 역직렬화 기본 개념

  • 직렬화 (Serialization): 객체를 바이트 스트림으로 변환하여 파일, 네트워크 전송 등을 할 수 있게 만듭니다.
  • 역직렬화 (Deserialization): 바이트 스트림을 다시 원래의 객체 형태로 복원하는 과정입니다.

커스텀 직렬화

Java에서 기본적으로 Serializable 인터페이스를 구현하면 객체는 자동으로 직렬화됩니다. 그러나 writeObject와 readObject 메서드를 사용하면 직렬화/역직렬화 과정을 커스터마이즈할 수 있습니다.

  • writeObject(ObjectOutputStream out) 메서드: 객체를 직렬화할 때 호출됩니다.
  • readObject(ObjectInputStream in) 메서드: 객체를 역직렬화할 때 호출됩니다.

주요 목적

  • 특정 필드를 직렬화에서 제외하거나 포함할 수 있습니다.
  • 직렬화 과정에서 암호화압축을 추가하거나, 성능 최적화를 할 수 있습니다.
  • 객체의 버전 관리 (예: 클래스 변경 시 이전 버전 객체의 역직렬화 처리)를 할 수 있습니다.

예시 1: 필드 제외 및 커스터마이즈

transient 키워드를 사용하면 직렬화에서 특정 필드를 제외할 수 있지만, writeObject와 readObject를 사용하면 더 세밀한 제어가 가능합니다.

커스터마이즈 예시: 특정 필드 제외

import java.io.*;

class Person implements Serializable {
    private String name;
    private transient int age;  // 직렬화에서 제외할 필드
    private String address;

    public Person(String name, int age, String address) {
        this.name = name;
        this.age = age;
        this.address = address;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject(); // 기본 직렬화
        // 'age'는 직렬화에서 제외
        oos.writeInt(age); // 커스텀 직렬화: 'age'는 별도로 저장
    }

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ois.defaultReadObject(); // 기본 역직렬화
        // 'age'를 역직렬화
        this.age = ois.readInt(); // 커스텀 역직렬화: 'age' 복원
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + ", address='" + address + "'}";
    }
}

public class CustomSerializationExample {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // 객체 직렬화
        Person person = new Person("John", 30, "1234 Elm St");
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.ser"))) {
            oos.writeObject(person);
        }

        // 객체 역직렬화
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.ser"))) {
            Person deserializedPerson = (Person) ois.readObject();
            System.out.println("Deserialized Person: " + deserializedPerson);
        }
    }
}

핵심 포인트:

  • transient로 필드를 제외할 수도 있지만, writeObject와 readObject를 사용하여 별도로 제어할 수 있습니다.
  • age 필드는 transient로 제외되었으므로 기본 직렬화에서는 저장되지 않으며, 커스터마이즈된 방식으로 저장되고 복원됩니다.

예시 2: 버전 관리 및 호환성

Java 직렬화에서는 클래스의 버전 관리가 중요합니다. serialVersionUID 필드를 사용하여 클래스의 버전 호환성을 관리할 수 있습니다. 그러나 writeObject와 readObject를 사용하면, 버전 변경 시 이전 버전 객체의 역직렬화를 처리할 수 있습니다.

커스터마이즈 예시: 객체 버전 관리

import java.io.*;

class PersonV1 implements Serializable {
    private static final long serialVersionUID = 1L;  // 첫 번째 버전

    private String name;
    private int age;

    public PersonV1(String name, int age) {
        this.name = name;
        this.age = age;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        // V1에서 V2로 버전 변경된 필드를 처리할 수 있습니다.
    }

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
    }

    @Override
    public String toString() {
        return "PersonV1{name='" + name + "', age=" + age + "}";
    }
}

class PersonV2 implements Serializable {
    private static final long serialVersionUID = 2L;  // 두 번째 버전

    private String name;
    private int age;
    private String address;  // 새로운 필드 추가

    public PersonV2(String name, int age, String address) {
        this.name = name;
        this.age = age;
        this.address = address;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();  // V2의 직렬화
        // 추가된 'address' 필드 직렬화
    }

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        // V1에서 V2로 변환 시, address 필드를 기본값으로 설정하거나 다른 처리할 수 있음
    }

    @Override
    public String toString() {
        return "PersonV2{name='" + name + "', age=" + age + "', address='" + address + "'}";
    }
}

public class VersionedSerializationExample {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // PersonV1 직렬화
        PersonV1 personV1 = new PersonV1("Alice", 28);
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("personV1.ser"))) {
            oos.writeObject(personV1);
        }

        // PersonV2 역직렬화 (기존 V1 객체를 V2로 읽기)
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("personV1.ser"))) {
            PersonV2 personV2 = (PersonV2) ois.readObject();
            System.out.println("Deserialized PersonV2: " + personV2);
        }
    }
}

핵심 포인트:

  • 객체 버전 관리에서 중요한 점은 기존 버전 객체를 새로운 버전으로 변환할 수 있어야 한다는 것입니다.
  • writeObject와 readObject를 활용하여 새로운 필드변경된 필드를 처리할 수 있습니다.
  • serialVersionUID는 클래스 버전 관리에 도움을 줍니다. 만약 클래스가 변경되어도, 호환성 있게 객체를 역직렬화할 수 있습니다.

예시 3: 객체 내부 상태 제어 (압축, 암호화)

직렬화/역직렬화 과정에서 암호화압축을 추가하여 데이터를 보호하거나 성능을 최적화할 수 있습니다.

커스터마이즈 예시: 압축 및 암호화

import java.io.*;
import java.util.zip.*;
import java.security.*;

class SecurePerson implements Serializable {
    private String name;
    private int age;

    public SecurePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        // 압축된 직렬화
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        try (ObjectOutputStream tempOos = new ObjectOutputStream(byteStream)) {
            tempOos.writeObject(this);
        }

        // 압축
        try (GZIPOutputStream gzip = new GZIPOutputStream(oos)) {
            gzip.write(byteStream.toByteArray());
        }
    }

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        // 압축된 데이터 해제
        try (GZIPInputStream gzip = new GZIPInputStream(ois);
             ObjectInputStream tempOis = new ObjectInputStream(gzip)) {
            SecurePerson person = (SecurePerson) tempOis.readObject();
            this.name = person.name;
            this.age = person.age;
        }
    }

    @Override
    public String toString() {
        return "SecurePerson{name='" + name + "', age=" + age + "}";
    }
}

public class SecureSerializationExample {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // 객체 직렬화 (압축 및 암호화)
        SecurePerson person = new SecurePerson("Bob", 40);
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("securePerson.ser"))) {
            oos.writeObject(person);
        }

        // 객체 역직렬화 (압축 해제 및 복원)
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("securePerson.ser"))) {
            SecurePerson deserializedPerson = (SecurePerson) ois.readObject();
            System.out.println("Deserialized SecurePerson: " + deserializedPerson);
        }
    }
}

핵심 포인트:

  • 직렬화 데이터에 압축이나 암호화를 추가하여 보안성이나 성능 최적화를 강화할 수 있습니다.
  • writeObject와 readObject에서 압축/해제 또는 암호화/복호화 처리를 추가할 수 있습니다.

정리

  • **writeObject**와 **readObject**는 커스터마이즈된 직렬화역직렬화 과정을 통해 객체의 직렬화 동작을 세밀하게 제어할 수 있습니다.
  • 이를 사용하여 특정 필드 제외, 버전 관리, 압축 및 암호화 등의 다양한 기능을 구현할 수 있습니다.
  • 직렬화와 역직렬화 과정에서의 보안, 성능 최적화데이터 무결성 등을 더 잘 다룰 수 있는 강력한 도구입니다.
반응형
반응형

✅ 1. Idempotency (중복 처리 방지)

💡 목적

MQTT는 QoS 1/2 사용 시 메시지가 중복 전송될 수 있으므로
Kafka Consumer나 DB 처리 단계에서 중복 방지가 필요합니다.

✅ 전략

  • MQTT 메시지에 messageId 또는 eventId 필드 포함 (UUID 등)
  • Kafka Consumer가 해당 ID를 기준으로 처리 여부 조회
  • 이미 처리된 경우 skip (DB 또는 Redis 등에 처리 이력 저장)

✅ 예시

{
  "messageId": "c2e2a8dd-52aa-403f-bb6d-1a7a2eeac341",
  "deviceId": "abc123",
  "timestamp": "2025-06-27T10:30:00Z",
  "data": { "temp": 25.3 }
}

✅ 처리 예 (Kafka Consumer):

if (messageRepository.existsByMessageId(message.getMessageId())) {
    log.info("Duplicate message: {}", message.getMessageId());
    return; // 중복 skip
}

messageRepository.save(message); // 최초 처리

※ 또는 Redis에 messageId를 TTL과 함께 저장하여 빠르게 검사


✅ 2. Topic Routing (MQTT → Kafka 토픽 매핑)

💡 목적

MQTT의 토픽 구조(/device/abc/temp)를 Kafka에 맞는 구조(device.abc.temp)로 변환

✅ 기본 전략

  • / → . 치환 또는
  • 패턴 기반 변환: 정규식 or prefix 매핑

✅ 예시 구현

public String mapMqttToKafkaTopic(String mqttTopic) {
    return mqttTopic.replace("/", ".");
}

또는 더 복잡한 경우:

if (mqttTopic.matches("/device/[^/]+/temp")) {
    return "device.temp";
} else if (mqttTopic.matches("/sensor/.+")) {
    return "sensor.data";
} else {
    return "unknown.topic";
}

✅ 3. DLQ (Dead Letter Queue) 처리

💡 목적

Kafka publish나 Consumer 처리 실패 시 메시지를 별도 토픽(DLQ)으로 보내고
문제 분석 또는 재처리 대상으로 격리

✅ 구현 전략 (Kafka publish 실패 시):

try {
    kafkaTemplate.send(topic, message).get(); // 동기 전송 (예외 캐치용)
} catch (Exception e) {
    log.error("Kafka publish failed: {}", e.getMessage());
    kafkaTemplate.send("deadletter.mqtt", message);
}

deadletter.mqtt는 운영 Kafka 클러스터에서 모니터링하거나 Alert 대상으로 사용 가능

✅ Kafka Consumer → DLQ 처리 예시도 가능:

Spring Kafka에서 ErrorHandler를 통해 DLQ 자동 라우팅 설정 가능


✅ 4. JWT / TLS 기반 MQTT 보안 설정

💡 목적

MQTT Broker(예: EMQX, Mosquitto 등)와의 통신 시 기기 인증과 전송 보안을 확보


✅ TLS 인증 (Broker 측 설정)

EMQX 예시:

listener.ssl.external {
  enable: true
  bind: 8883
  keyfile: etc/certs/server.key
  certfile: etc/certs/server.crt
  cacertfile: etc/certs/ca.crt
}

Spring MQTT Client 설정:

java
복사편집
MqttConnectOptions options = new MqttConnectOptions(); options.setSocketFactory(SSLSocketFactoryFactory.get("ca.crt", "client.crt", "client.key"));

직접 인증서 기반 인증(TLS mutual authentication)


✅ JWT 인증 (EMQX)

  1. JWT를 username 또는 password 필드에 담아 전송
  2. EMQX에서 JWT 인증 플러그인 활성화
  3. PEM 키 또는 JWKS URL 기반 서명 검증

Spring MQTT 설정:

MqttConnectOptions options = new MqttConnectOptions();
options.setSocketFactory(SSLSocketFactoryFactory.get("ca.crt", "client.crt", "client.key"));

📌 종합 구조 예시

options.setUserName("jwt");
options.setPassword("<jwt_token>".toCharArray());

📝 요약


 

항목 구현 포인트 도구/구현 위치
Idempotency messageId → Redis/DB로 중복검사 Kafka Consumer
Topic Routing MQTT topic → Kafka topic 규칙 변환 Spring App 또는 EMQX
DLQ 처리 Kafka publish 실패 시 fallback KafkaTemplate try-catch
보안 연동 TLS 인증서, JWT token 사용 MQTT Client + Broker 설정

 

반응형
반응형

🧭 요약: Kafka가 하는 역할

"실시간 수신 데이터를 분산 처리 가능한 구조로 중계해주는 중심 허브 역할"


🔍 Kafka의 역할 상세 정리

1. 비동기 처리 (Decoupling)

  • WebSocket 서버는 데이터를 즉시 Kafka에 넣고 종료 (빠름)
  • 이후 처리 로직(MariaDB 저장, 알림 발송, 로그 기록 등)은 Kafka consumer들이 비동기적으로 수행

✅ WebSocket 서버의 부담이 줄고, 처리 지연 없이 응답 가능
✅ 소비 로직은 느려도 상관없음


2. 데이터 버퍼/완충지 역할 (Buffering)

  • 순간적으로 데이터 폭주가 발생해도 Kafka가 데이터를 큐 형태로 저장해서 순차 처리 가능
  • 예: 1초에 수천 개 메시지가 들어와도 consumer가 순차 처리

✅ 시스템 안정성 증가
✅ 장애 시 메시지 유실 방지 (Kafka는 디스크에 저장)


3. 데이터 전달 허브 (Routing)

  • 여러 consumer들이 동일한 topic을 구독할 수 있음
  • 예:
    • consumer A: MariaDB 저장
    • consumer B: 실시간 대시보드 반영
    • consumer C: 이상 탐지 분석

✅ 하나의 데이터로 여러 기능 분기 가능
✅ 마이크로서비스 구조와 궁합 좋음


4. 재처리 가능성 확보 (Reprocessing)

  • Kafka는 데이터를 로그처럼 저장하기 때문에,
  • 일정 시간(예: 7일) 내에는 이전 메시지 재처리 가능

✅ 예전 데이터를 다시 분석하거나 저장 가능
✅ 소비자가 장애났다가 복구되어도 처리 재시도 가능


5. 확장성 (Scalability)

  • Kafka는 topic을 partition으로 나누고,
  • consumer group을 늘려서 병렬 소비 가능

✅ 대규모 IoT/임베디드 데이터에도 대응 가능
✅ 소비자가 여러 인스턴스로 scale-out 가능


🔁 비교: Kafka 없이 직접 DB 저장했을 경우

항목 Kafka 미사용 Kafka 사용
WebSocket 서버 처리 부담 높음 낮음
장애 전파 범위 큼 (DB/네트워크 장애 시 전체 영향) 작음 (consumer만 영향)
실시간 분석/대시보드 연동 구현 복잡 여러 consumer로 분리 가능
확장성 낮음 높음
재처리 어려움 가능 (offset 기반)
 

🧪 실무 예시

Kafka Topic 구독 Consumer
device.data MariaDB 저장 서비스
device.alert 알림 시스템 (FCM, Slack)
device.log 로그/감사 이력 저장 시스템
device.analytics Spark/Flink 스트리밍 분석 시스템
 

📌 결론

Kafka는 단순히 “전송해주는 애”가 아니라 다음과 같은 역할을 수행합니다:

✅ 빠르게 받고
✅ 큐잉하고
✅ 다양한 서비스로 나눠 보내고
✅ 장애를 막고
✅ 대용량을 감당하며
✅ 재처리까지 가능한 통합 메시지 허브

반응형

+ Recent posts

목차