Stream은 데이터를 파이프라인처럼 연결해서 처리하는 방법

    데이터 소스 → 중간 연산 → 최종 연산
    (배열/리스트)  (가공/필터)   (결과 추출)
    

    1. 스트림 생성

    // 배열에서 생성
    int[] arr = {1, 2, 3, 4, 5};
    Arrays.stream(arr)
    
    // 리스트에서 생성
    List<String> list = List.of("A", "B", "C");
    list.stream()
    

    2. 중간 연산 (가공)

    map - 각 요소를 변환

    // int → String 변환
    Arrays.stream(numbers)
        .mapToObj(String::valueOf)
    // [1, 2, 3] → ["1", "2", "3"]
    
    // 각 요소에 2 곱하기
    Arrays.stream(arr)
        .map(x -> x * 2)
    // [1, 2, 3] → [2, 4, 6]
    
    // 문자열 대문자로 변환
    list.stream()
        .map(String::toUpperCase)
    // ["a", "b"] → ["A", "B"]

    filter - 조건에 맞는 요소만 추출

    // 짝수만 필터링
    Arrays.stream(arr)
        .filter(x -> x % 2 == 0)
    // [1, 2, 3, 4, 5] → [2, 4]
    
    // 길이가 3 이상인 문자열만
    list.stream()
        .filter(s -> s.length() >= 3)
    // ["AB", "ABC", "ABCD"] → ["ABC", "ABCD"]
    

    sorted - 정렬

    // 기본 오름차순
    stream.sorted()
    // [3, 1, 2] → [1, 2, 3]
    
    // 내림차순
    stream.sorted(Comparator.reverseOrder())
    // [3, 1, 2] → [3, 2, 1]
    
    // 커스텀 정렬 (문자열 길이 순)
    stream.sorted((a, b) -> a.length() - b.length())
    

    distinct / limit / skip

    // 중복 제거
    stream.distinct()
    // [1, 1, 2, 2, 3] → [1, 2, 3]
    
    // 앞에서 3개만
    stream.limit(3)
    // [1, 2, 3, 4, 5] → [1, 2, 3]
    
    // 앞에서 2개 건너뛰기
    stream.skip(2)
    // [1, 2, 3, 4, 5] → [3, 4, 5]
    

    3. 최종 연산 (결과 추출)

    toArray - 배열로 변환

    // int 배열로
    int[] result = Arrays.stream(arr)
        .toArray();
    
    // String 배열로
    String[] result = list.stream()
        .toArray(String[]::new);
    

    collect - 리스트/맵으로 변환

    // List로 변환
    List<Integer> result = Arrays.stream(arr)
        .filter(x -> x > 2)
        .collect(Collectors.toList());
    // [1, 2, 3, 4, 5] → [3, 4, 5]
    
    // Map으로 변환
    Map<String, Integer> result = list.stream()
        .collect(Collectors.toMap(
            s -> s,          // key
            s -> s.length()  // value
        ));
    // ["AB", "ABC"] → {"AB":2, "ABC":3}
    

    reduce - 요소를 하나로 합치기

    // 전체 합계
    int sum = Arrays.stream(arr)
        .reduce(0, (a, b) -> a + b);
    // [1, 2, 3, 4, 5] → 15
    
    // 전체 곱
    int product = Arrays.stream(arr)
        .reduce(1, (a, b) -> a * b);
    // [1, 2, 3, 4, 5] → 120
    

     

    첫 번째 인자는 초기값(identity) ➡︎ 연산 결과에 영향을 주지 않는 값

    • 덧셈 → 초기값 0  (0 + x = x)
    • 곱셈 → 초기값 1  (1 * x = x)
    • 최댓값 → 초기값 Integer.MIN_VALUE
    • 최솟값 → 초기값 Integer.MAX_VALUE

    숫자 관련 최종 연산

    // 합계
    Arrays.stream(arr).sum()         // 15
    
    // 평균
    Arrays.stream(arr).average()     // 3.0
    
    // 최댓값
    Arrays.stream(arr).max()         // 5
    
    // 최솟값  
    Arrays.stream(arr).min()         // 1
    
    // 개수
    Arrays.stream(arr).count()       // 5
    

    forEach / anyMatch / allMatch

    // 각 요소 출력
    stream.forEach(System.out::println);
    
    // 하나라도 조건 만족?
    boolean result = stream.anyMatch(x -> x > 3);  // true
    
    // 전부 조건 만족?
    boolean result = stream.allMatch(x -> x > 0);  // true
    

    4. 코테에서 자주 쓰이는 패턴

    // int[] → List<Integer>
    List<Integer> list = Arrays.stream(arr)
        .boxed()
        .collect(Collectors.toList());
    
    // List<Integer> → int[]
    int[] arr = list.stream()
        .mapToInt(Integer::intValue)
        .toArray();
    
    // 2차원 배열 평탄화
    int[] flat = Arrays.stream(matrix)
        .flatMapToInt(Arrays::stream)
        .toArray();
    // [[1,2],[3,4]] → [1,2,3,4]
    
    // 문자열 → 각 문자 배열
    "hello".chars()
        .mapToObj(c -> String.valueOf((char) c))
        .toArray(String[]::new);
    // ["h","e","l","l","o"]
    

    한눈에 보는 흐름

    [1, 2, 3, 4, 5]
        ↓ .filter(x -> x % 2 == 0)
      [2, 4]
        ↓ .map(x -> x * 10)
      [20, 40]
        ↓ .collect(Collectors.toList())
      List<Integer> [20, 40]
    

     

    Stream은 한 번 쓰고 버리는 일회용이라서, 같은 스트림을 두 번 사용하면 에러(필요하면 새로 생성)

    'Spring' 카테고리의 다른 글

    모여모여 트러블 슈팅 모음  (0) 2026.02.11
    JPA N+1 문제  (0) 2025.10.23
    EntityManager (엔티티 매니저)  (0) 2025.10.22
    JPA ddl-auto 옵션  (0) 2025.10.21
    Spring Data JPA에서 새로운 Entity인지 판단하는 방법  (0) 2025.10.20

    프로젝트 1: 모여모여

    1. 트러블 슈팅 1: Redis + Kafka 기반 동시성 제어

    정확한 구현 내용

    1) 문제 상황

    • 챌린지 참여 시 여러 사용자가 동시에 마지막 남은 슬롯에 접근할 경우 중복 참여 가능
    • DB 단일 트랜잭션으로는 동시성 제어가 완벽하지 않음

     

    2) 해결 방식: 2단계 예약 시스템

     

    [1단계] 슬롯 예약 (check API)

    // ChallengeParticipationServiceImpl.java - Line 109-125
    
    private void reserveSlotOrFail(String challengeId, Challenge challenge, String userId) {
        String slotsKey = "challenge:{challengeId}:slots";     // 남은 슬롯 수
        String pendingKey = "challenge:{challengeId}:pending:{userId}";  // 예약 상태
    
        // 1. 남은 슬롯이 없으면 Redis에 초기화
        int remaining = challenge.getMaxParticipants() - challenge.getParticipantsCount();
        redisTemplate.opsForValue().setIfAbsent(slotsKey, String.valueOf(remaining));
    
        // 2. Redis DECR로 원자적으로 슬롯 차감 (핵심!)
        Long remain = redisTemplate.opsForValue().decrement(slotsKey);
    
        // 3. 슬롯이 부족하면 복구 후 예외 발생
        if (remain == null || remain < 0) {
            if (remain != null && remain < 0) {
                redisTemplate.opsForValue().increment(slotsKey);  // 롤백
            }
            throw new CustomException(ErrorCode.CHALLENGE_PARTICIPATION_CLOSED);
        }
    
        // 4. 성공 시 pending 키 생성 (TTL 5분)
        redisTemplate.opsForValue().set(pendingKey, "true", Duration.ofMinutes(5));
    }

     

    핵심 포인트:

    • decrement(slotsKey): Redis의 원자적 연산으로 동시성 보장
    • remain < 0 체크: 슬롯을 초과해서 차감한 경우 즉시 복구
    • TTL 5분: 결제를 완료하지 않으면 자동 슬롯 반환

     

    [2단계] 결제 완료 후 최종 확정 (participate API → Kafka)

    // ChallengeParticipationServiceImpl.java - Line 64-83
    
    public void participate(String challengeId, ChallengeParticipationRequestDto requestDto) {
        String userId = currentUser.getId();
        String pendingKey = "challenge:{challengeId}:pending:{userId}";
    
        // 1. pending 키가 있는지 확인 (5분 이내 결제 완료해야 함)
        if (!redisTemplate.hasKey(pendingKey)) {
            throw new CustomException(ErrorCode.NO_PENDING_RESERVATION);
        }
    
        // 2. Kafka로 이벤트 발행 (비동기 처리)
        ChallengeParticipationEvent event = ChallengeParticipationEvent.builder()
                .challengeId(challengeId)
                .userId(userId)
                .paymentId(requestDto.getPaymentId())
                .build();
    
        participationProducer.sendParticipationCompleteEvent(event);
    }

     

    [3단계] Kafka Consumer가 최종 DB 저장

    // ChallengeParticipationConsumer.java - Line 37-62
    
    @KafkaListener(topics = "challenge.participation.complete", groupId = "challenge-consumer")
    @Transactional
    public void consume(String messageJson) {
        ChallengeParticipationEvent message = objectMapper.readValue(messageJson, ...);
        String challengeId = message.getChallengeId();
        String userId = message.getUserId();
        String pendingKey = "challenge:{challengeId}:pending:{userId}";
    
        // 1. 중복 체크: pending 키가 여전히 있는지 확인
        participationValidator.validateHasPending(pendingKey, challengeId, userId);
    
        // 2. DB 중복 체크 (재시도/중복 메시지 방어)
        participationValidator.validateNotParticipated(challengeId, userId);
    
        // 3. DB에 최종 저장
        ChallengeParticipation participation = participationMapper.toParticipant(challenge, user);
        payment.updateParticipation(participation);
        challenge.updateParticipantsCount();
        participationRepository.save(participation);
    
        // 4. pending 키 삭제
        redisTemplate.delete(pendingKey);
    }

     

    3) TTL 만료 시 슬롯 자동 복구

    // RedisExpirationListener.java - Line 34-73
    
    @Bean
    public MessageListenerAdapter expiredEventListener() {
        return new MessageListenerAdapter((MessageListener) (message, pattern) -> {
            String expiredKey = message.toString();
            // "__keyevent@0__:expired" 채널 구독
    
            // "challenge:{challengeId}:pending:{userId}" 형식 확인
            String[] parts = expiredKey.split(":");
            if (parts.length == 4 && "challenge".equals(parts[0]) && "pending".equals(parts[2])) {
                String challengeId = parts[1];
                String slotsKey = "challenge:" + challengeId + ":slots";
    
                // 슬롯 키가 존재하면 +1 복구
                if (redisTemplate.hasKey(slotsKey)) {
                    redisTemplate.opsForValue().increment(slotsKey);
                    log.info("[Redis] TTL 만료 복구 완료, challengeId = {}", challengeId);
                }
            }
        });
    }

     

    동작 흐름:

    1. 사용자가 결제를 5분 내에 완료하지 않으면 pending:{userId} 키가 자동 만료
    2. Redis가 __keyevent@0__:expired 이벤트 발행
    3. Listener가 감지하여 slots 키를 +1 증가 (슬롯 복구)

    Kafka 멱등성(Idempotency) 보장 방식

    1) Producer 측: 기본 설정

    # application.yml
    spring:
      kafka:
        producer:
          key-serializer: org.apache.kafka.common.serialization.StringSerializer
          value-serializer: org.apache.kafka.common.serialization.StringSerializer

     

    현재 상태:

    • Producer에서 enable.idempotence=true 설정은 명시적으로 없음
    • 하지만 Kafka 3.0 이상에서는 기본값이 true로 설정됨

    면접 답변:

    "Kafka Producer는 3.0 이상 버전에서 기본적으로 멱등성이 활성화되어 있어,
    네트워크 재시도 시에도 동일한 메시지가 중복으로 저장되지 않습니다."

     

    2) Consumer 측: 비즈니스 로직으로 멱등성 보장

     

    방법 1: DB 중복 체크

    // ChallengeParticipationConsumer.java - Line 47
    participationValidator.validateNotParticipated(challengeId, userId);
    • DB에 이미 참여 기록이 있으면 예외 발생
    • 같은 메시지가 여러 번 처리되어도 DB에는 1번만 저장됨

     

    방법 2: Pending 키 체크

    // Line 46
    participationValidator.validateHasPending(pendingKey, challengeId, userId);
    • pending 키가 이미 삭제되었으면 (이미 처리된 경우) 예외 발생

    면접 답변:

    "Kafka Consumer에서 멱등성을 보장하기 위해 두 가지 방어 로직을 구현했습니다:

    1. pending 키 존재 여부 확인 (Redis)
    2. DB 중복 참여 체크 (validateNotParticipated)

    이를 통해 동일한 이벤트가 여러 번 consume되더라도
    DB에는 한 번만 저장되도록 멱등성을 보장했습니다."


    추가 개선 포인트

    Q: 실제 부하 테스트는 수행했나요?

    추천 답변:

    "Postman으로 기본 시나리오를 검증했고, Redis의 원자적 연산과 Kafka의 이벤트 기반 처리를 통해
    이론적으로 동시성을 보장하는 구조를 설계했습니다.

    실제 부하 테스트는 수행하지 못했지만, 향후 JMeter를 활용해
    초당 100~500명의 동시 요청 시나리오를 테스트하여
    Redis DECR의 원자성과 슬롯 복구 메커니즘이 정상 작동하는지 검증할 계획입니다."

    Q: Redis 장애 시 대응 방안은?

    추천 답변:

    "현재는 Redis 단일 인스턴스를 사용하고 있어 Redis 장애 시 서비스가 중단됩니다.

    개선 방안:

    1. Redis Sentinel: 자동 failover로 고가용성 확보
    2. DB Fallback: Redis 장애 시 DB 락(SELECT FOR UPDATE)으로 전환
    3. Circuit Breaker 패턴: Redis 장애를 감지하고 일시적으로 참여 중단"

    2. 트러블 슈팅 2: Spring Batch 벌크 업서트

    정확한 구현 내용

    1) 문제 상황

    • 일일 루틴 통계 집계를 실시간 API에서 처리 → 응답 지연 발생
    • 특정 날짜 기준 재집계가 불가능

     

    2) 해결: Tasklet 기반 Spring Batch

     

    Batch Job 구조:

    // ChallengeBatchConfig.java
    
    @Bean
    public Job statusTransitionJob() {
        return new JobBuilder("StatusTransitionJob", jobRepository)
                .incrementer(new RunIdIncrementer())
                .listener(loggingJobExecutionListener)  // 실행 시간 로깅
                .start(challengeStatusTransitionStep())  // Step 1
                .next(participationStatusTransitionStep())  // Step 2
                .build();
    }

     

    Step 구성 (Tasklet 방식):

    // ParticipationStatusTransitionTasklet.java
    
    @Component
    @StepScope
    public class ParticipationStatusTransitionTasklet implements Tasklet {
    
        @Value("#{jobParameters['targetDate']}")
        private String targetDate;  // 재집계 날짜 지정 가능!
    
        @Override
        public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
            LocalDate date = LocalDate.parse(targetDate);
            participationService.updateStatus(date);  // 상태 전환 로직
            return RepeatStatus.FINISHED;
        }
    }

     

    실행 방법:

    # 특정 날짜 재집계
    java -jar app.jar --job.name=statusTransitionJob targetDate=2025-01-15
    
    # 어제 날짜로 자동 실행 (스케줄러)

     

    3) 벌크 업서트 구현

    // RoutineStatUpsertRepositoryImpl.java
    
    private static final int BATCH_SIZE = 500;
    private static final String SQL = """
        INSERT INTO routine_stat (...)
        VALUES (...)
        ON CONFLICT (user_id, start_date)  -- PostgreSQL UPSERT
        DO UPDATE SET
            total_minutes = EXCLUDED.total_minutes,
            ...
        """;
    
    @Override
    public void upsertAll(List<RoutineStatReadResponseDto> stats) {
        List<SqlParameterSource> buffer = new ArrayList<>(BATCH_SIZE);
    
        for (RoutineStatReadResponseDto r : stats) {
            buffer.add(new MapSqlParameterSource()
                    .addValue("userId", r.userId())
                    .addValue("startDate", r.startDate())
                    ...
            );
    
            // 500건씩 묶어서 한 번에 실행
            if (buffer.size() == BATCH_SIZE) {
                jdbcTemplate.batchUpdate(SQL, buffer.toArray(SqlParameterSource[]::new));
                buffer.clear();
            }
        }
    
        // 나머지 처리
        if (!buffer.isEmpty()) {
            jdbcTemplate.batchUpdate(SQL, buffer.toArray(SqlParameterSource[]::new));
        }
    }

     

    성과:

    • 쿼리 실행 횟수 90% 감소: 1000건 → 2건 (500건씩 배치)
    • 처리 속도 60% 개선: N번 INSERT → 2번 BATCH INSERT

    Tasklet vs ChunkOrientedTasklet 비교

    구분 Tasklet ChunkOrientedTasklet
    사용 목적 단순 로직 실행 (상태 업데이트, 집계) 대량 데이터 처리 (Read-Process-Write)
    구조 한 번에 실행 Chunk 단위로 반복 (예: 1000건씩)
    트랜잭션 Tasklet 전체가 1개 트랜잭션 Chunk마다 트랜잭션 커밋
    적용 사례 모여모여 프로젝트 (상태 전환) 대량 CSV 파일 처리, 마이그레이션

     

    면접 답변:

    "모여모여 프로젝트에서는 Tasklet 방식을 선택했습니다.

    이유:

    1. 일일 루틴 집계는 전체 데이터를 한 번에 처리해도 되는 규모 (수백~수천 건)
    2. 단순 상태 전환 로직이므로 ChunkOrientedTasklet의 복잡한 구조가 불필요
    3. Tasklet + JDBC Batch Update 조합으로 충분히 성능 확보

    만약 데이터가 수십만 건 이상으로 증가하면,
    ChunkOrientedTasklet으로 전환하여 메모리 부담을 줄일 수 있습니다."


    JobParameters 재실행 메커니즘

    Q: 실패한 Step만 재실행 가능한가요?

     

    현재 구조:

    @Value("#{jobParameters['targetDate']}")
    private String targetDate;
    • JobParameters로 날짜를 지정하면 전체 Job을 재실행
    • 특정 Step만 재실행하려면 추가 설정 필요

    추천 답변:

    "현재는 JobParameters로 날짜를 지정해 전체 Job을 재실행하는 방식입니다.

    특정 Step만 재실행하려면:

    1. allowStartIfComplete(true) 설정: 완료된 Step도 재실행 허용
    2. Step 단위 Job 분리: statusTransitionJob1, statusTransitionJob2
    3. Spring Batch Admin: UI에서 Step 단위 재시작

    현재 프로젝트에서는 Step이 2개뿐이고 실행 시간이 짧아
    전체 재실행이 더 간단하고 안전한 방식이라고 판단했습니다."


    3. 트러블 슈팅 3: Gemini AI 프롬프트 고도화

    정확한 구현 내용

    1) 문제 상황

    • AI 응답이 중복 문장으로 채워짐
    • JSON 파싱 실패 (자유 텍스트 반환)
    • 토큰 초과로 응답 누락

     

    2) 해결: 구조화된 프롬프트 + Schema 강제

     

    프롬프트 구조:

    // AbstractAiClient.java - Line 11-34
    
    protected String settingPrompt(RoutineStat stat) {
        return """
            당신은 개인 학습 습관을 분석하고 동기를 부여하는 AI 코치입니다.  // 역할 정의
    
            주간 총 공부 시간: %s (분)  // 데이터 삽입
            하루 평균: %s (분)
            집중 요일: %s
            적게 공부한 요일: %s
            출석률 높은 요일: %s
    
            아래 JSON 스키마로만 출력하세요. 각 필드는 **간결**, **중복 금지**:  // 명확한 지시
            - routineAnalysis: 사실 요약만. 동일 문구 반복 금지.
            - emotionalFeedback: 격려 톤, 과장/나열/중복 금지.
            - nextWeekRoutine: 구체적 요일/목표 포함, 나열/중복 금지.
            """.formatted(stat.getTotalMinutes(), stat.getAvgMinutes(), ...);
    }

     

    JSON Schema 강제 출력:

    // GeminiClient.java - Line 98-121
    
    ObjectNode schema = objectMapper.createObjectNode();
    schema.put("type", "OBJECT");
    
    ObjectNode props = objectMapper.createObjectNode();
    ObjectNode stringType = objectMapper.createObjectNode()
            .put("type", "STRING")
            .put("maxLength", 300);  // 길이 제한!
    
    props.set("routineAnalysis", stringType);
    props.set("emotionalFeedback", stringType);
    props.set("nextWeekRoutine", stringType);
    schema.set("properties", props);
    
    ObjectNode generationConfig = objectMapper.createObjectNode();
    generationConfig.put("responseMimeType", "application/json");  // JSON 강제
    generationConfig.set("responseSchema", schema);
    generationConfig.put("temperature", 0.2);  // 창의성 낮춤
    generationConfig.put("maxOutputTokens", 1024);  // 토큰 제한

     

    응답 검증 및 예외 처리:

    // GeminiClient.java - Line 70-76
    
    JsonNode root = objectMapper.readTree(json);
    if (root.path("routineAnalysis").isMissingNode()
            || root.path("emotionalFeedback").isMissingNode()
            || root.path("nextWeekRoutine").isMissingNode()) {
        throw new CustomException(ErrorCode.AI_BAD_RESPONSE);
    }
    return objectMapper.treeToValue(root, Report.class);

     

    3) 성과 측정

    테스트 방법:

    • 50건의 샘플 데이터로 응답 검증
    • 각 필드 길이, 중복 문장, JSON 파싱 성공률 측정

    결과:

    • 응답 성공률: 100% (50/50건)
    • 평균 응답 시간: 약 2~3초
    • JSON 파싱 실패: 0건

    📌 최종 정리

    동시성 제어 (Redis + Kafka)

    Q: 어떻게 동시성을 보장했나요?

    "Redis의 DECR 연산은 원자적(atomic)으로 동작하여 동시에 여러 요청이 와도
    슬롯 차감이 순차적으로 처리됩니다. 이후 Kafka로 비동기 처리하여
    DB 저장과 슬롯 관리를 분리했습니다."

     

    Q: Redis 장애 시 어떻게 하나요?

    "현재는 Redis 단일 인스턴스를 사용하지만, 향후 Redis Sentinel 도입으로
    고가용성을 확보하거나, Circuit Breaker 패턴으로 장애 감지 시
    DB 락(SELECT FOR UPDATE) 방식으로 Fallback할 수 있습니다."


    Spring Batch

    Q: Tasklet을 선택한 이유는?

    "일일 집계 데이터 규모가 수백~수천 건 수준이라
    단순 Tasklet + JDBC Batch Update로 충분히 성능을 확보할 수 있었습니다.
    ChunkOrientedTasklet은 수십만 건 이상의 데이터 처리 시 고려할 예정입니다."

     

    Q: 재집계는 어떻게 하나요?

    "JobParameters에 targetDate를 지정하여 특정 날짜 기준으로 재실행 가능합니다.
    예: java -jar app.jar targetDate=2025-01-15"


    AI 프롬프트 고도화

    Q: 무료 API의 한계는?

    "무료 API는 분당 요청 수 제한이 있지만,
    일일 루틴 리포트는 실시간성이 중요하지 않아 비동기 배치 처리로 우회했습니다.
    사용자가 증가하면 유료 API로 전환하거나 캐싱 전략을 추가할 계획입니다."


    🎯 추가 어필 포인트

    1. 확장 가능한 아키텍처

    • Redis + Kafka 조합으로 MSA 전환 용이
    • Batch 처리 분리로 실시간 API와 무관하게 스케일 가능

    2. 운영 안정성

    • Redis TTL 자동 복구로 결제 미완료 시 슬롯 자동 반환
    • Kafka Consumer 멱등성 보장으로 재시도에도 안전

    3. 모니터링 & 로깅

    • LoggingJobExecutionListener로 Batch 실행 시간 자동 기록
    • Kafka Producer/Consumer에서 파티션, 오프셋 로깅

    📚 참고: 프로젝트 기술 스택 정리

    카테고리 기술
    언어 & 프레임워크 Java, Spring Boot, Spring Batch
    동시성 제어 Redis (DECR, TTL, KeyExpiration Listener)
    비동기 처리 Kafka (Producer/Consumer)
    데이터베이스 PostgreSQL (UPSERT, JDBC Batch)
    AI 연동 Gemini API (gemini-2.5-flash)
    인프라 AWS EC2, RDS, Docker, GitHub Actions

     

    'Spring' 카테고리의 다른 글

    Java Stream 정리  (0) 2026.02.17
    JPA N+1 문제  (0) 2025.10.23
    EntityManager (엔티티 매니저)  (0) 2025.10.22
    JPA ddl-auto 옵션  (0) 2025.10.21
    Spring Data JPA에서 새로운 Entity인지 판단하는 방법  (0) 2025.10.20

    JPA ID 생성 전략

    직접 할당

    개발자가 직접 ID 값을 설정하는 방식

    @Id 어노테이션만을 사용하여 Id 값을 직접 할당하는 방식

    @Entity
    public class User {
        @Id
        private Long id; // 직접 지정
    }
    • ex) 비즈니스 키를 직접 ID로 사용하는 경우 (이메일, 코드 등)

    자동 생성

    JPA가 자동으로 ID를 생성하는 방식

    @Id, @GeneratedValue 를 함께 사용해 원하는 키 생성 전략을 선택하는 방식

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @GeneratedValue 의 stretagy 4가지 옵션

    @Target({ElementType.METHOD, ElementType.FIELD})  
    @Retention(RetentionPolicy.RUNTIME)  
    public @interface GeneratedValue {  
        GenerationType strategy() default GenerationType.AUTO;  
    
        String generator() default "";  
    }
    
    public enum GenerationType { 
        AUTO,
        IDENTITY,
        SEQUENCE, 
        TABLE
    }

    AUTO 전략

    사용하는 DB Dialect에 따라 JPA 가 적절한 전략을 자동으로 선택

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    • DB 교체 시 코드 수정 불필요
    • DB 변경 시 전략이 바뀌면 ID 생성 방식도 달라질 수 있음

    IDENTITY 전략

    기본 키 생성을 DB의 AUTO_INCREMENT 기능에 위임하는 방식

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    • MySQL, PostgreSQL, SQL Server 등에서 사용하는 방식
    • 식별자가 DB에서 사용되므로 쓰기 지연 적용 X → 즉시 INSERT
    • Batch Insert 에서는 적용 불가
    • 트래픽이 많지 않은 서비스나 단순 CRUD에 적합

    동작 과정

    INSERT 시 DB가 자동으로 ID 생성
    
    ⬇︎
    
    JPA가 생성된 키 조회 (getCeratedKeys())

    SEQUENCE 전략

    DB의 Sequence 객체를 사용해 고유 ID 생성

    @SequenceGenerator(
        name = "USER_SEQ_GENERATOR",
        sequenceName = "USER_SEQ",
        allocationSize = 1
    )
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ_GENERATOR")
    private Long id;
    • Oracle, PostgreSQL, H2 등에서 사용하는 방식
    • 식별자를 미리 확보하므로 쓰기 지연 가능
    • Batch Insert 가능, 성능 우수
    • 시퀀스 캐시 옵션 (allocationSize) 조정으로 성능 튜닝 가능

    동작 과정

    @SequenceGenerator 로 시퀀스 이름 지정
    
    ⬇︎
    
    persist() 시 미리 시퀀스 값을 조회 후 엔티티에 설정

    TABLE 전략

    키 생성 전용 테이블(key_generator)을 만들어 시퀀스 역할 수행

    @TableGenerator(
        name = "USER_TABLE_GENERATOR",
        table = "KEY_GENERATOR",
        pkColumnValue = "USER_SEQ",
        allocationSize = 1
    )
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "USER_TABLE_GENERATOR")
    private Long id;
    • 모든 RDBMS 에서 사용 가능하지만, 성능 저하 이슈
    • SERECT + UPDATE 로 DB 통신 2회 발생
    • 특정 DB 종속성을 피해야 하는 멀티 DB 환경에 적합

    동작 과정

    다음 값 조회 (SERECT)
    
    ⬇︎
    
    증가 후 UPDATE

    출처: maeil-mail 매일메일 - JPA에서 ID 생성 전략에 대해 설명해주세요.

    로그 (Log)

    정의

    애플리케이션, 서버, 또는 시스템이 동작하는 동안 발생한
    상태, 이벤트, 오류, 요청 정보 등을 시간순으로 기록한 데이터

     

    • 문맥(Context) 기반 정보로, 특정 시점에 무엇이, 왜 발생했는지 추적하는 데 용이
    • 주로 문제 발생 시 원인 분석(트러블 슈팅)을 위해 활용

     

    예시

    2025-10-30 14:12:45 [INFO] User 123 login success
    2025-10-30 14:12:46 [ERROR] Payment API Timeout

     

    특징

    항목 설명
    기록 단위 개별 이벤트(요청, 예외, 상태 변화 등)
    형태 텍스트 기반, 시점 중심
    주요 목적 오류 원인 파악, 요청 추적, 디버깅
    저장 수명 보통 단기 보관 (1~14일 등)
    예시 툴 Logback, Log4j2, Loki, ELK(Elasticsearch + Logstash + Kibana)

     

    로그 수집: Logback + MDC + Loki

    • Logback: Spring Boot 기본 로깅 프레임워크
    • MDC(Mapped Diagnostic Context): 요청 단위 추적을 위한 Context ID 추가
    • → 로그 상에서 요청 단위 흐름을 쉽게 파악 가능
    • Loki: 로그를 중앙집중식으로 저장, Grafana 와 통합해 시각화
    • →ex) “최근 5분동안 ERROR 로그 발생 추이” 대시보드 표시

    Logback 설정 예시

    <pattern>
      %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %X{traceId} %logger{36} - %msg%n
    </pattern>

    메트릭 (Metric)

    정의

    시스템의 상태, 성능, 자원 사용량 등을 정량적인 수치로 표현한 통계적 지표 데이터

    • 현재 시스템이 어떤 상태인지, 현재의 흐름과 경향성을 보여줌

     

    예시

    분류 지표 예시 수집 이유
    시스템 리소스 CPU 사용률, 메모리 사용량, 디스크 I/O 서버의 과부하나 리소스 병목 현상을 조기에 감지하여 장애를 예방하고, 성능 저하 원인을 분석하기 위함
    JVM 상태 힙 메모리, GC 횟수, 스레드 개수 애플리케이션의 메모리 누수나 GC(가비지 컬렉션) 병목, 스레드 과다 생성 등 JVM 내부의 안정성을 확인하기 위함
    애플리케이션 성능 요청 처리 속도, 에러율, TPS(Transactions Per Second) 사용자의 요청을 처리하는 성능을 측정하고, 응답 지연·에러율 증가 등으로 인한 서비스 품질 저하를 조기 감지하기 위함
    비즈니스 지표 DAU(Daily Active Users), Retention, 결제 성공률 단순 기술 성능뿐 아니라 실제 비즈니스 성과(유저 활동·매출 지표)를 추적하여 서비스 개선 방향을 파악하기 위함
    에러 로그 메트릭 ERROR 레벨 로그 발생 수, 예외 발생 빈도, API 실패율 예외 발생 추이와 특정 기능의 실패율을 수치화하여 장애 징후를 조기에 인식하고, 로그 분석의 기준 데이터로 활용하기 위함
    스레드/커넥션 풀 상태 톰캣 스레드 풀 사용률, HikariCP 커넥션 풀 대기 수, 활성 커넥션 수 요청이 몰릴 때 스레드나 DB 커넥션이 부족해지는 현상을 모니터링하여, 리소스 풀 설정을 최적화하고 응답 지연을 방지하기 위함

    로그와 메트릭 비교

    구분 로그(Log) 메트릭(Metric)
    데이터 형태 비정형 텍스트 정형 숫자 (시계열)
    기록 시점 이벤트 발생 시마다 일정 주기 (예: 5초, 30초 단위)
    목적 원인 분석, 요청 추적 시스템 모니터링, 경향 분석
    저장 기간 단기 (며칠 단위) 장기 (주·월 단위)
    분석 방식 개별 로그 분석 평균, 합계, 퍼센트 등 집계 분석
    대표 도구 Logback, ELK, Loki Prometheus, Grafana, CloudWatch

    로그: 무엇이, 왜 일어났는가 (What + Why)

    메트릭: 지금 어떤 상태인가 (How Well)


    실무 예시

    로그 & 메트릭 통합 수집 환경

    [Spring Boot App]
       ├─(메트릭)─>  Actuator (/actuator/prometheus)
       │               │
       │               └─(pull/scrape)─> Prometheus ──> Grafana(메트릭 대시보드)
       │
       └─(로그)─> Logback/Log4j2 ──> Promtail ──(push)─> Loki ──> Grafana(로그 탐색)
    • Spring Boot Actuator: /actuator/metrics, /actuator/health 엔드포인트로 메트릭 제공
    • Prometheus: Spring Boot에서 노출한 메트릭을 주기적으로 스크래핑하여 수집
    • Grafana: Prometheus 데이터를 기반으로 대시보드 시각화
    • Logback + Loki: 로그 파일을 Loki 로 전송하고, Grafana에서 로그와 메트릭을 함께 모니터링

    System.out.println 대신 로깅 프레임워크 쓰는 이유

    구분 System.out.println 로깅 프레임워크(Logback, Log4j2 등)
    성능 즉시 출력 (I/O 부하 발생) 비동기 처리 가능, 버퍼링 처리
    로그 레벨 불가능 TRACE, DEBUG, INFO, WARN, ERROR 지원
    환경별 필터링 불가능 dev/test/prod 환경별 로그 수준 조절
    출력 위치 콘솔만 가능 콘솔 + 파일 + 원격 서버(Loki, ELK 등)
    운영 적합성 단순 테스트용 운영환경 필수 구성요소
    • System.out.println 은 개발 중 디버깅 용도로만 사용
    • 운영 환경에서는 반드시로깅 프레임워크(Logback, Log4j2 등) 사용

    참고: maeil-mail 매일메일 - 로그와 메트릭을 설명해주세요.

    얕은 복사와 깊은 복사

    구분 기준

    자바에서 객체를 복사할 때, 다음 기준에 따라 구분

    • 객체의 참조를 그대로 복사하느냐,
    • 객체 내부의 필드까지 새로 생성해서 복사하느냐

    얇은 복사 (Shallow Copy)

    객체 자체만 복사하고, 내부에 포함된 참조 타입(객체)은 같은 인스턴스 공유

    깊은 복사 (Deep Copy)

    객체뿐 아니라 내부 참조 객체도 모두 새로 생성하여 완전히 독립적인 복제본 생성


    코드 예시

    • Book
      • String name (책의 이름)
      • Author author (저자)
    • Author
      • String name (저자 이름)

    클래스 정의

    class Book {
    
        private String name;      // 책 이름
        private Author author;    // 저자
    
        public Book(String name, Author author) {
            this.name = name;
            this.author = author;
        }
    
        // 얕은 복사
        public Book shallowCopy() {
            return new Book(this.name, this.author); // Author 공유
        }
    
        // 깊은 복사
        public Book deepCopy() {
            Author copiedAuthor = new Author(this.author.getName());
            return new Book(this.name, copiedAuthor); // Author 새로 생성
        }
    
        public void changeAuthor(String name) { // 저자 이름 변경
            author.setName(name);
        }
    
        @Override
        public String toString() {
            return "Book name : " + name + ", " + author;
        }
    
        // 내부 클래스
        static class Author {
            private String name; // 저자 이름
    
            public Author(String name) {
                this.name = name;
            }
    
            public String getName() { // 저자 이름 반환
               return name;
            }
    
            public void setName(String name) { // 저자 이름 변경
               this.name = name;
            }
    
            @Override
            public String toString() {
                return "Author : " + name;
            }
        }
    
    }

    메인

    public static void main(String[] args) {
        Author author1 = new Author("조슈아 블로크");
        Book book1 = new Book("이펙티브 자바", author1);
    
        // 얕은 복사 후 변경
        Book shallowCopyBook = book1.shallowCopy();
        shallowCopyBook.changeAuthor("Joshua Bloch");
    
       // 얕은 복사 결과 출력
        System.out.println("After shallow copy and change:");
        System.out.println("Original book1: " + book1);
        System.out.println("Shallow copied book: " + shallowCopyBook);
    
        Author author2 = new Author("마틴 파울러");
        Book book2 = new Book("리팩터링", author2);
    
        // 깊은 복사 후 변경
        Book deepCopyBook = book2.deepCopy();
        deepCopyBook.changeAuthor("Martin Fowler");
    
       // 깊은 복사 결과 출력
        System.out.println("\nAfter deep copy and change:");
        System.out.println("Original book2: " + book2);
        System.out.println("Deep copied book: " + deepCopyBook);
    }

    출력 결과

    After shallow copy and change:
    Original book1: Book name : 이펙티브 자바, Author : Joshua Bloch
    Shallow copied book: Book name : 이펙티브 자바, Author : Joshua Bloch
    
    After deep copy and change:
    Original book2: Book name : 리팩터링, Author : 마틴 파울러
    Deep copied book: Book name : 리팩터링, Author : Martin Fowler

     

    얕은 복사

    • Book 객체는 새로 생성되었지만, 내부의 Author 필드는 원본 객체와 동일한 참조 공유
    • shallowCopyBook 의 저자를 변경하면, 원본 book1 의 저자도 함께 변경
    • ⇒ 두 Book 객체가 같은 Author 인스턴스 참조

    깊은 복사

    • BookAuthor 모두 새로운 객체로 생성되어 완전히 독립적인 복제본
    • deepCopyBook 의 저자를 변경해도, 원본 book2 의 저자는 변경되지 않음
    • ⇒ 두 Book 객체가 서로 다른 Author 인스턴스 가짐

    요약 정리

    구분 얕은 복사 (Shallow Copy) 깊은 복사 (Deep Copy)
    복사 범위 객체의 참조만 복사 객체 내부의 모든 참조 객체까지 새로 복사
    참조 관계 원본과 복사본이 같은 객체 참조 원본과 복사본이 서로 다른 객체 참조
    데이터 독립성 ❌ (하나 수정 시 다른 객체도 영향) ✅ (완전히 독립적)
    성능 빠름 (참조만 복사) 느림 (객체 새로 생성)
    사용 예시 변경이 없는 객체 복제, 캐싱 등 객체 상태를 완전히 분리해야 하는 경우 (예: DTO 변환, 스냅샷)

    참조: maeil-mail 매일메일 - 얕은 복사와 깊은 복사에 대해서 설명해주세요.

    트랜잭션 격리 수준

    정의

    동시에 여러 트랜잭션이 실행될 때 서로의 데이터 변경이 어느 정도까지 보이는가를 결정하는 기준

    • 낮은 격리 수준 → 동시성 ⬆️, 일관성 ⬇️
    • 높은 격리 수준 → 일관성 ⬆️, 성능 ⬇️

    ⇒ 데이터 정합성과 성능은 반비례 관계


    필요성

    • DB는 동시에 여러 사용자가 같은 데이터를 읽고 수정할 수 있어야 함 → 동시성 문제 발생 가능
    • 트랜잭션 격리 수준이 이런 문제를 조절하여, “얼마나 엄격하게 트랜잭션을 분리할지”를 정하는 기준

    종류

    1. READ UNCOMMITTED (읽기 미완료 허용)

    가장 낮은 격리 수준
    다른 트랜잭션이 커밋하지 않은 데이터도 읽을 수 있음

    • 성능은 빠르지만, 데이터 신뢰성 최악

     

    예시

    T1: salary = 100 → 200 변경 후 아직 커밋 X
    
    T2: salary = 200 읽음
    
    T1: 롤백으로 salary = 100

    ⇒ T2는 잘못된 값을 읽고 사용한 게 됨 = Dirty Read

     

     

    발생 가능한 문제

    • Dirty Read
    • Non-Repeatable Read
    • Phantom Read

     

    2. READ COMMITTED (읽기 완료 허용)

    커밋된 데이터만 읽을 수 있음
    트랜잭션이 진행 중일 때, 다른 트랜잭션의 커밋된 변경사항은 바로 조회 가능

    • 대부분 DB의 기본 격리 수준 (Oracle, PostgreSQL)
    • 성능과 일관성의 균형점

     

    예시

    T1: balance = 1000 읽음
    
    T2: balance = 1000 → 2000 변경 후 커밋
    
    T1: 다시 balance 읽으면 값 2000

     

     

    발생 가능한 문제

    • Non-Repeatable Read
    • Phantom Read

     

    3. REPEATABLE READ (반복 가능한 읽기)

    한 트랜잭션 내에서는 동일한 쿼리에 항상 동일한 결과 반환
    다른 트랜잭션이 커밋하더라도, 현재 트랜잭션이 시작 시점의 스냅샷 기준으로 데이터 조회

    • MySQL(InnoDB)의 기본 격리 수준

     

    예시

    T1: price = 100 읽음
    
    T2: price = 100 → 200 변경 후 커밋
    
    T1: 다시 price 조회 시, 값 100

     

    생 가능한 문제

    • Phantom Read

     

    4. SERIALIZABLE (직렬화)

    가장 높은 수준 격리
    한 트랜잭션이 읽고 있는 테이블을 다른 트랜잭션이 접근하면 잠금 발생

    • 모든 트랜잭션을 순차적으로 실행하는 것처럼 처리
    • 발생 가능한 문제는 없지만, 동시성 처리 성능 급격히 저하됨

     

    예시

    T1: 특정 조건의 SELECT 실행 중
    T2: T1 이 조회 중인 데이터를 삽입/수정/삭제할 수 없음

    문제 현상

    1. Dirty Read (더티 리드)

    • 다른 트랜잭션이 아직 커밋하지 않은 변경 데이터를 읽음
    T1: salary = 100 → 200 변경 후 아직 커밋 X
    
    T2: salary = 200 읽음
    
    T1: 롤백으로 salary = 100
    

    2. Non-Repeatable Read (반복 불가능 읽기)

    • 한 트랜잭션이 같은 데이터를 두 번 읽었을 때 값이 다름
    T1: balance = 1000 읽음
    
    T2: balance = 1000 → 2000 변경 후 커밋
    
    T1: 다시 balance 읽으면 값 2000
    

    3. Phantom Read (팬텀 리드)

    • 한 트랜잭션이 같은 조건의 쿼리를 두 번 실행했을 때 결과 행의 개수가 다름
    T1: SELECT * FROM user; → 20
    
    T2: INSERT user ~;
    
    T1: SELECT * FROM user; → 20
    

    요약

    구분 Dirty Read Non-Repeatable Read Phantom Read
    READ UNCOMMITTED
    READ COMMITTED
    REPEATABLE READ (MySQL에서는 ❌)
    SERIALIZABLE

    참고: maeil-mail 매일메일 - 트랜잭션 격리수준은 무엇인가요?

    + Recent posts