5주차 발표 save - g-market/b-shop-backend GitHub Wiki

📈 진행률

📝 금주 수행 작업

📌 재고 관련 동시성 제어

1) 어떤 lock을 이용하여 제어할지

  • Synchronized
    • 서버가 2대 이상일 경우, 동시성 제어 불가능
  • Pessimistic Lock(비관적 락)
    • 실제로 데이터에 lock을 걸어 정합성을 맞추는 방식
    • deadlock 발생 가능성, db에 직접 lock 걸기 때문에 성능저하가 유발될 수 있다.
  • Optimistic Lock(낙관적 락)
    • version을 통해 정합성을 맞춘다.
    • rollback 이 필요한 경우 직접 로직 작성해야함.
  • Redisson
    • Pub-sub 기반으로 Lock 구현 제공
    • 스핀락 방식을 사용하지 않고, timeout 기능을 통한 deadlock 방지, 분산환경에서 적합.

2) 재고 관련 고려해야 하는 기능

  • 주문 시 재고 감소
  • 주문 취소 시 재고 증가
  • 관리자가 재고를 update

3) 구현 과정 중 고민점

  • 주문 생성 로직 @Transactional
@Transactional
public OrderCreateResponseDto createOrder(final Long memberId, final 
	OrderCreateRequestDto orderCreateRequestDto) {
	OrderMapper.INSTANCE.ordersCreateDtoToEntity(memberId, orderCreateRequestDto);
	...
	itemOption.decreaseStockQuantity(count);
	...
	order.createOrder(orderItemList);
	orderRepository.save(order);
}

=> Facade 패턴을 이용하여 상위 계층 구성

@Service
public class OrderLockFacade {
	...
	public OrderCreateResponseDto purchase(final Long memberId, final OrderCreateRequestDto orderCreateRequestDto) {
		Order order = orderService.createOrder(memberId, orderCreateRequestDto);
		//lock
		List<OrderItem> orderItemList = lockItemOptionList(orderCreateRequestDto.orderItemDtoList(), order);
		//lock finish
		OrderCreateResponseDto responseDto = orderService.saveOrder(order, orderItemList);
	
		return responseDto;
	}
}

=> Redisson을 사용한 lock 구현

public List<OrderItem> lockItemOption(List<OrderItemDto> list, Order order) {
	for (int i = 0; i < list.size(); i++) {
		RLock lock = redissonClient.getLock(String.format("%d", list.get(i).itemOptionId()));
		lockList.add(lock);
	}
	...
	try {
		boolean available = false;
		for (RLock rlock : lockList) {
			available = rlock.tryLock(5, 5, TimeUnit.SECONDS);
		}

		if (!available) {
			System.out.println("redisson getLock timeout");
			throw new IllegalArgumentException();
		}else {
			returnList = orderService.lockItemOptionList(list, order);
		}
	} catch (InterruptedException e) {
		throw new RuntimeException(e);
	} finally {
		lockList.forEach(rLock -> rLock.unlock());
	}
	return returnList;
}  

=> 개선해야할 점

  • timeout 시간
rlock.tryLock(5, 5, TimeUnit.SECONDS); // watiTime, leaseTime
  • Lock을 걸 때 최소한의 데이터로
  • DB IO 최소화

  • 장바구니

  • 모니터링(Sentry)

  • 카테고리별 조회 기능 구현

  • 카테고리 관리 기능 구현

📌 minIO 이미지 업로드

  • 이미지를 업로드하고 상품이미지 테이블을 CRUD 할 수 있는 API 구현

    CODE
    • Controller

       @Login(admin = true)
       @PostMapping("/images")
       public ResponseEntity<List<ImageResponse>> uploadImage(
       @RequestParam("fileList") MultipartFile[] fileList) {
       return ResponseEntity.ok().body(imageService.uploadImage(fileList));
       }
    • Service

      private final MinioClient minioClient;
      
      private final int MAX_IMAGE_UPLOAD_COUNT = 10; // TO_BE_CHANGED
      @Value("${minio.bucket}")
      private String bucketName;
      
      @Value("${minio.endpoint}")
      private String endpoint;
      
      public List<ImageResponse> uploadImage(final MultipartFile[] fileList) {
      
          if (fileList.length > MAX_IMAGE_UPLOAD_COUNT) {
              throw new ConflictException(MAX_FILE_UPLOAD_REQUEST_EXCEPTION, MAX_IMAGE_UPLOAD_COUNT);
          }
          List<ImageResponse> imageResponseList = new ArrayList<>();
      
          try {
              for (MultipartFile file : fileList) {
                  final String fileName = UUID.randomUUID().toString();
                  final PutObjectArgs putObjectArgs = PutObjectArgs.builder()
                      .bucket(bucketName)
                      .object(fileName)
                      .stream(file.getInputStream(), file.getSize(), -1)
                      .contentType(file.getContentType())
                      .build();
      
                  minioClient.putObject(putObjectArgs);
      
                  imageResponseList.add(ImageResponse.builder()
                      .fileName(file.getOriginalFilename())
                      .url(endpoint + "/" + bucketName + "/" + fileName).build()
                  );
              }
          } catch (Exception e) {
              throw new InternalServerException(MINIO_UPLOAD_EXCEPTION);
          }
      
          return imageResponseList;
      
      }```

<<-----------IMAGE---------------->>

📌 envers 적용(상품 트랜잭션 로그)

  • 상품의 변경이력을 DB 테이블에서 관리할 수 있는 envers 라이브러리를 사용
  • <TABLE_NAME>_aud 형태로 이력을 관리하는 테이블이 자동 생성되어 변경내역을 확인 가능

<<-----------IMAGE---------------->>

📌 상품 예약

  • 상품의 상태를 RESERVED -> PUBLIC 으로 변경하기 위한 scheduler 를 구현.
  • 여러개의 인스턴스가 동일한 스케쥴 된 작업을 중복적으로 수행하지 않게하기 위해 shedlock 라이브러리를 사용해 작업을 동기화.
  • 1분마다 상품 오픈예약 정보가 들어있는 Reserve 테이블에서 현재시간 보다 이전인 예약 내역을 조회하고 상태변경

<<-----------IMAGE---------------->>

CODE
@Scheduled(cron = "0 * * * * *") // 1분 마다 실행
@SchedulerLock(
name = "scheduledItemStatusUpdateTask",
lockAtLeastFor = "5s",
lockAtMostFor = "10s")
@Transactional
public void updateReservationStatus() {
final List<Reservation> reservationList = itemReserveRepository.findAllByItemOpenAtBefore(LocalDateTime.now());

  for (Reservation reservation : reservationList) {
      if (reservation.getItem().getItemStatus() == ItemStatus.RESERVED) {
          reservation.getItem().setItemStatus(ItemStatus.PUBLIC);
      }
      itemReserveRepository.delete(reservation);
  }
}

📌 서버 인프라 세팅

  • b-shop 인프라를 VM 내부에 구성

    • mysql:8.0.31
    • redis:6.2.7(보안설정 적용)
    • minio:latest
    docker-compose.yml
      version: "3.8"
      services:
        b-shop-redis:
          container_name: b-shop-redis
          image: redis:6.2.7
          environment:
            - TZ=Asia/Seoul
          ports:
            - ${REDIS_PORT}:6379
          networks:
            - b-shop-network
          volumes:
            - ./redis/redis.conf:/usr/local/etc/redis/redis.conf
          command: redis-server /usr/local/etc/redis/redis.conf
        b-shop-database:
          container_name: b-shop-database
          image: mysql:8.0.31
          environment:
            - MYSQL_DATABASE=${DB_NAME}
            - MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
            - TZ=Asia/Seoul
          volumes:
            - ./database/config:/etc/mysql/conf.d
          ports:
            - ${DB_PORT}:3306
          networks:
            - b-shop-network
        b-shop-minio:
          container_name: b-shop-minio
          image: quay.io/minio/minio
          volumes:
            - minio_storage:/data
          ports:
           - ${MINIO_PORT}:9000
           - ${MINIO_WEB_PORT}:9001
          environment:
            - MINIO_ROOT_USER=${MINIO_USER}
            - MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
          command: server --console-address ":9001" /data
        create_bucket:
          container_name: create-bucket
          image: minio/mc
          depends_on:
            - b-shop-minio
          entrypoint: >
            /bin/sh -c "
            mc config host add minio http://b-shop-minio:${MINIO_WEB_PORT} ${MINIO_USER} ${MINIO_PASSWORD};
            mc mb minio/${MINIO_BUCKET};
            mc anonymous set public minio/${MINIO_BUCKET};
            "
      
      volumes:
        minio_storage: {}
      
      networks:
        b-shop-network:
          external: true
    

    📌 배포 이미지, 스트립트 작성

    • 배포를 위한 docker-compose, .env 작성
    • 이미지 gitlab container registry push 단계까지 진행
      variables:
      IMAGE_NAME: mentoring-gitlab.gabia.com:5050/mentee/mentee_2023.01/team/g-market/gabia_b_shop_backend
      
      package-and-push:
        rules:
          - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
        stage: package
        image: docker:latest
        services:
          - name: docker:dind
        before_script:
          - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        script:
          - docker pull $IMAGE_NAME:latest || true
          - docker build --cache-from $IMAGE_NAME:latest --tag $IMAGE_NAME:$CI_COMMIT_SHA --tag $IMAGE_NAME:latest .
          - docker push $IMAGE_NAME:$CI_COMMIT_SHA
          - docker push $IMAGE_NAME:latest
        after_script:
          - docker logout

    <<-----------IMAGE---------------->>

    고민했던 이슈 또는 개선 작업?

    나뉘어진 docker-compose.yml 에서 컨테이너 이름으로 연결정보를 전달하기 위한 시행착오(해결)

    인프라 구성과 배포를 위해 compose 파일을 분할 한 이후 mysql, redis에 접근을 해야하는데 다른 compose 파일 간 컨테이너 이름으로 연결이 안됨

    • 1차 시도 : host network를 백엔드 컨테이너에 연결 -> localhost로 연결은 되지만 포트가 바인딩 안됨
    • 2차 시도 : bridge network를 생성하여 컨테이너를 연결 -> 실패
    • 3차 시도 : external_links를 사용 -> 실패
    • 4차 시도 : 프로젝트를 다시 빌드하고 이미지 생성 -> 성공...

    이미지에 대한 제거 API를 구현할지 고민중

    • 현재에는 이미지를 업로드 하는 기능만 구현되어 있는데 itemImage 레코드가 제거될 경우 minio 버킷에 올라간 이미지도 함께 제거되지는 않음
    • 상품과 연관된 테이블은 모두 soft delete 하도록 설계되어있기 때문
    • 버킷에 올라간 이미지를 어떻게 관리해야할지 추가적인 논의 필요

    envers를 적용한 상품 정보 이력 관리 기능에 대한 이슈

    • 상품에 연결된 테이블(카테고리, 상품옵션, 상품이미지)에서 변경이 일어날 경우에도 이력이 관리되도록 설정이 되어있음
    • 어떤 테이블의 어떤 컬럼까지 이력관리를 해야하는지 의사결정을 해야하는 상황 -> (ex) 상품 재고 같은 경우 변경이 빈번히 일어나기 때문에 이력관리가 필요한지 등..
    • 변경 이력을 조회하는 API 가 필요할지(?)

    TODO

    • 기존 코드 리팩토링
    • CD 파이프라인
      • dockerfile 작성
      • gitlab container registry 등록
      • nginx
        • blue green 배포전략을 적용해 무중단 배포환경을 구축
    • 동시성 제어
      • Redis를 통한 thread락 기능을 적용해 재고에 대한 동시성 제어 로직을 구현하고자 함
    • 프론트엔드 구현
      • 백엔드 기능 구현이 끝나면 시작
    • 상품 조회(카테고리별, 년별) 기능 추가적으로 구현
    • 상품관련 CRUD 기능 테스트코드 추가 구현
⚠️ **GitHub.com Fallback** ⚠️