1/6

AI·2026년 1월 6일

백엔드 이슈 1

비관적 락

동시성 문제가 매우 빈번하게 발생할 것이다 => 적극적으로 해결
DB 특성, 성질 이용
select 한 후, update를 수행할 것이므로 lock을 건 select 수행

repository

	// #1. 비관적 락
	@Lock(LockModeType.PESSIMISTIC_WRITE) // select ~ for update
	@Query("select c from Coupon c where c.id = :id")
	Optional<Coupon> findByIdWithLock(@Param("id") Long id);

service

	// #1. 비관적 락
	Coupon coupon = couponRepository.findByIdWithLock(couponId)
		.orElseThrow(() -> new IllegalArgumentException("It doesn't exist coupon"));
	coupon.setQuantity(coupon.getQuantity()-1);

낙관적 락

동시성 문제가 거의 발생하지 않을 것이다. 만약 발생하면 그것에 대응하는 코드로 해결
DB 특성, 성질 이용
select 한 후, update를 수행할 때, select때의 버전(동일 엔티티 조건)이 아니면 예외 발생
=> 특정 thread의 해당 엔티티 건에 대한 시도 실패
=> 실패로 그냥 끝냄(버리기) or 실패한 thread 다시 시도
동시성 문제가 많이 발생하면 수행 시간이 비관적 락보다 더 오래 걸림
거의 발생하지 않으면, 더 적게 걸린다

기본 코드와 동일 => facade 추가

Facade

생성

package com.mycom.myapp.service;

import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

// #02. 낙관적 락 시도할 때, CouponService의 시도에서 예외가 발생한 경우 처리
// CouponConcurrencyTest에서 CouponService 대신 사용
@Component
@RequiredArgsConstructor
public class CouponFacade {
	private final CouponService couponService;
	
	public void issue(Long couponId) throws InterruptedException{
		// 특정 Thread가 호출하면 성공할 때까지 반복적으로 재시도
		while(true) {
			try {
				couponService.issue(couponId);
				// 예외 발생하지 않고 issue()가 수행되면 성공
				break;
			}catch(ObjectOptimisticLockingFailureException e) {
				// 버전 출동 발생
				// -> 잠깐 대기 후 다시 while을 통해서 재시도
				System.out.println(e.getMessage());
				Thread.sleep(50);
			}catch(Exception e) {
				break;
			}
		}
	}
}

entity

에 추가

	// #2. 낙관적 락을 위한 버전 관리 필드
	@Version
	private Long version;

Test

에 추가

	// #2. 낙관적 락
	@Autowired
	private CouponFacade couponFacade;
    
    // #0. 기본 코드, #1. 비관적 락 코드
	//couponService.issue(1L);
					
	// #2. 낙관적 락
	couponFacade.issue(1L);

결과는 동일

분산 락

Redis 사용 -> 건별 처리 어려움

https://github.com/redis-windows/redis-windows/releases

redis-server.exe 실행시키면 됨. 6379번 포트 사용

build.gradle에 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation("org.redisson:redisson-spring-boot-starter:4.1.0")

추가

spring.data.redis.host=localhost
spring.data.redis.port=6379

Facade

//#3. redis를 통한 분산 락 구현
@Component
@RequiredArgsConstructor
public class CouponFacade {
	private final RedissonClient redissonClient; // 라이브러리 클라이언트
	private final CouponService couponService;
	
	public void issue(Long couponId) {
		RLock lock = redissonClient.getLock("coupon_lock:"+couponId); // 락 이름 설정(couponId 별 유일)
		try {
			// 락 획득 시도 (줄 서기)
			// waitTime : ~동안 기다릴거야
			// leaseTime : 락을 얻고난 후 자동 반납 시간(길면 락을 통한 작업을 안정적으로 할 수 있지만, 시간이 오래 걸림
			// 				반대면 시간은 짧지만, 덜 안정적으로 처리
			boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 10초 대기
			
			// 대기 결과 확인
			if(!available) {
				// 비즈니스 로직별 처리
				System.out.println("There is too many users");
				// retry or termination
				return; // 이 thread는 실패로 종결
			}
			// 락 획득 성공
			couponService.issue(couponId);
			
		} catch(Exception e) {
			throw new IllegalArgumentException("system exection");
		} finally {
			// 락 반납
			// 락이 잠겨 있고, 현재 thread에 의한 것이면
			if(lock.isLocked() && lock.isHeldByCurrentThread()) {
				lock.unlock();
			}
		}
	}
}

System.out.println("There is too many users");에 재시도를 안하고 그냥 진행해서 -900과 차이가 발생하는 것임
newFixedThreadPool(32) 값이 커지면, 실패할 확률이 높아짐. 낮을수록 실패할 확률 낮아짐

프로젝트 개요

주제 : 대용량 통신 요금 명세서 및 알림 발송 시스템

6조로 나뉘어지고 5~6명으로 구성
~1/9 기획안
15일 멘토링, 2번의 팀원 평가
개인 과제 산출물 및 팀 산출물 존재
25분+Q&A 5분 발표
시상 2팀

멘토링은 한 조당 40분
각 조당 네이버페이 5만원 지원금 ex. openAI, aws

백엔드 이슈 2

queue에 쌓아서 꺼내는 작업인 batch로 선착순 controll 진행

CouponRedisRepository

package com.mycom.myapp.repository;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;

// Redis Queueing 작업 관련 처리
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
	private final RedisTemplate<String, String> redisTemplate;
	
	// queue에 추가(producer)
	public void addToQueue(Long userId) {
		// coupon_queue 이름의 리스트에 userId를 오른쪽에 추가
		redisTemplate.opsForList().rightPush("coupon_queue", String.valueOf(userId));
	}
	// queue에서 꺼내기(consumer)
	public Long popFromQueue() {
		// coupon_queue 이름의 리스트에 가장 먼저 들어온 userId를 왼쪽에서 꺼낸다
		String userId = redisTemplate.opsForList().leftPop("coupon_queue");
		return userId != null ? Long.valueOf(userId) : null;
	}
	// queue의 크기
	public Long getSize() {
		return redisTemplate.opsForList().size("coupon_queue");
	}
}

service

package com.mycom.myapp.service;

import org.springframework.stereotype.Service;

import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.repository.CouponRepository;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class CouponServiceImpl implements CouponService {

	private final CouponRepository couponRepository;
	private final CouponRedisRepository couponRedisRepository;

	// producer
	// 사용자가 쿠폰을 요청하면 queue에 넣고 마무리
	@Override
	public void apply(Long userId) {
		couponRedisRepository.addToQueue(userId);
		
	}

	// consumer
	// queue에서 꺼낸 유저에게(userId) 쿠폰 발급(DB)
	@Transactional
	@Override
	public void publish(Long userId) {
		Coupon coupon = couponRepository.findById(1L)
				.orElseThrow();
		// 단순 동시성 제어가 아닌 100개 선착순 제한 로직 추가 
		if(coupon.getQuantity() <= 0) {
			throw new SoldOutException(coupon.getName() + "All coupon have been used up");
		}
		
		// 소진되지 않은 경우
		coupon.setQuantity(coupon.getQuantity()-1);
		// 해당 user에게 쿠폰 발급 처리
		// 현재는 단순 출력
		// 사용자가 발급 여부 조회 요청을 하면 처리하는 코드 필요
		// db or 파일
		System.out.println(coupon.getName()+"issued completed, user :"+userId+", remaining quantity :"+coupon.getQuantity());
	}
}

exception

package com.mycom.myapp.exception;

// 쿠폰 발급 완료를 의미하는 사용자 정의 예외
public class SoldOutException extends RuntimeException{

	private static final long serialVersionUID = 1L;

	public SoldOutException(String message) {
		super(message);
	}
}

scheduler

package com.mycom.myapp.schedule;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.service.CouponService;

import lombok.RequiredArgsConstructor;

// 스프링에서 제공하는 간단한 배치 작업 용도 @Scheduled
// redis queue에 있는 userId를 순차적으로 가져와서 couponService의 publish 호출
@Component
@RequiredArgsConstructor
public class CouponScheduler {
	private final CouponRedisRepository couponRedisRepository;
	private final CouponService couponService;
	
	private boolean isSoldOut = false;
	
	// 스프링 스케줄러에 의해 호출되면 메소드가 끝날 때까지 반복적으로 redis queue에서 userId를 꺼내서 couponService publish 호출
	@Scheduled(fixedDelay = 100) // 1/10초 마다 호출
	public void couponEventScheduler() {
		while(true) {
			Long userId = couponRedisRepository.popFromQueue();
			
			// userId x
			if(userId == null) {
				break;
			}
			// sold out
			if(isSoldOut) {
				// 다양한 비즈니스 로직 및 처리 방안에 의해 코드 작성
				// userId 요청 시점에 soldout된 상태를 DB 또는 파일 저장 -> 이후 요청에 대응
				continue;
			}
			// userId + sold
			try {
				couponService.publish(userId);
			} catch(SoldOutException e) {
				isSoldOut = true;
				// 다양한 비즈니스 로직 필요
				// db에 저장, 기록 후 더 이상의 사용자 요청을 받지 않도록 처리 등
				System.out.println("FCFS coupon has expired");
			} catch(Exception e) {
				e.printStackTrace();
			}
			
		}
	}
}

test

package com.mycom.myapp;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.repository.CouponRepository;
import com.mycom.myapp.service.CouponService;

// 선착순
@SpringBootTest
public class CouponConcurrencyRedisTest {
	@Autowired
	private CouponRepository couponRepository;
	@Autowired
	private CouponService couponService;
	@Autowired
	private RedisTemplate<String, String> redisTemplate;
	
	@BeforeEach
	void setUp() {
		couponRepository.save(new Coupon("FCFS coupon", 100));
		redisTemplate.delete("coupon_queue"); // 초기화
	}
	
	@Test
	void queueingTest() throws Exception{
		int threadCount = 1000; // thread 1개 = user 1명
		ExecutorService executorService = Executors.newFixedThreadPool(32); 
		CountDownLatch latch = new CountDownLatch(threadCount);
			
		for(int i=0;i<threadCount;i++) {
			long userId = i; // 현재 코드 시나리오는 0번 userId부터 999번 userId까지 순차적으로 호출
			executorService.submit( () -> {
				try {
					couponService.apply(userId);
				}catch(Exception e) {
					e.printStackTrace();
				}finally {
					latch.countDown();
				}
			});
		}
		latch.await();
		
		// 1000명의 대기 큐 완성
		// scheduler가 일하는 시간 부여
		Thread.sleep(10000); // 10s
		
		long finalQuantity = couponRepository.findById(1L).orElseThrow().getQuantity();
		
		System.out.println("finalQuantity:"+finalQuantity);
		
		assertEquals(0, finalQuantity);
	}
}

SpringBootJpaBackendIssue2Application에 @EnableScheduling 추가

시험

폭포수 모델

  • 수정사항 반영 어려움
  • 요구분석 과정에서 개발 비용이 가장 많이 든다
  • 유스케이스 다이어그램 요소 : 액터, 유스케이스, 관계, 시스템 경계
  • 시퀀스 다이어그램 요소 : 객체, 생명선, 액션 바, 메세지
  • 요구분석 기능, 비기능 모두 중요
  • 단계별 산출물 :
    요구분석-요구분석 명세서,
    설계-sw 설계서,
    구현-프로그램, 코드,
    테스트 - 통과된 프로그램, 코드
  • 상세보고서를 보고 코딩

애자일

전통적인 개발 모델의 문제점 <- 애자일, 폭포수 모두 충분히 테스트가 가능한 모델
애자일과 전통적 방법 차이점 <- 계획 반복
스프린트 회고 3가지 - Keep, Problem, Try
프로덕트 백로그 우선 순위 결정 - PO
DB 어려운 문제 - 생성, 읽기, 갱신, 삭제
매일 아침 하는 거 - 일일 스크럼

0개의 댓글