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

📈 진행률

진행률 : 97.2%

⛓ 남은 작업 사항

  • 상품 조회(년도, 카테고리)
  • 주문에 대한 동시성제어

image

📝 금주 수행 작업

📌 재고 관련 동시성 제어

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 최소화

📌 장바구니

1. 기능 구현

  • 사용자마다 현재 담아놓은 상품 목록을 조회 및 24시간이 지나면 목록이 초기화

  • 마지막으로 담아놓은 상품을 기준으로 변경사항 없이 24시간이 지나면 목록이 초기화 되는 요구사항에는 영속성을 보장하는 데이터 베이스 보다는 레디스 TTL을 적용한 자료구조가 적합할 것으로 판단

  • 요구사항 24시간 기준으로 목록이 초기화 되는 것을 30초로 설정하고 테스트

Animation (1)

2. 고민 사항

  • Key 조회가 안되다니 말이돼??
  • in절 컬럼 2개를 통해 한번에 조회
    • 현재 장바구니에는 itemId, itemOptionId, orderCount에 정보가 담겨 있습니다
    • 사용자에게 제공되는 정보는 item 테이블의 정보와, itemOption 테이블의 정보등 다양한 정보들이 있습니다.
    • 이에 따라 데이터베이스에서 이러한 정보를 가져온 후, 애플리케이션에서 orderCount를 조립하여 사용자에게 제공하였습니다.
    • 이렇게 함으로써 레디스에서 조회된 itemId, itemOptionId에서 있지만 혹시 모른 이빨빠진..? itemId, itemOptionId에 대해서 얘기치 못한 오류가 발생함도 줄일 수 있습니다.

📌 모니터링

1. 기능 구현

  • Dependency: implementation 'io.sentry:sentry-spring-boot-starter-jakarta:6.14.0'
  • Application.yaml: sentry.dsn = ${SENTRY-DSN}

image

2. 고민 사항

  • 에러에 대한 Monitoring이 안된다...?

    • Application.yaml에 sentry.dsn = ${SENTRY-DSN}만 기입하면 연결이 되고 에러 모니터링을 보내면 확인할 수 있을거라 오만했습니다..

    • image

    • 현재 사용하는 DNS 및 IP에서는 SSL 인증서가 없어 외부 서버인 sentry로, 오류에 대한 통신을 할 수 없었습니다.

    • 어떻게 에러 찾았냐!...

      왜 안보내지는지에 대해 고민을 엄청하였는데 WIFI 바꾸니까 한번에 성공....? 및 블로깅을 통해 찾게 되었습니다..

    • 해결 방법은 Jun님의 도움으로 해결할 수 있었습니다.

      • 첫 번째로 http://gabia.cc/에서 인증서를 다운 받아, JVM에 설치하여 해결하였습니다.
  • 이제 에러 모니터링이 되겠지..?...이게 끝이 아니였다..

    • 현재 글로벌 ExceptionHandling 처리는 ControllerAdvice인 exception#GlobalExceptionHandler가 역할을 수행하고 있습니다.
    • Sentry는 기본적으로 처리되지 않은 예외만 Sentry로 전송됩니다.
    • 이는 ExceptionControllerAdvie의 ExceptionHandling의 순번이 더 먼저이기에 Sentry로 보내지지 않는 것입니다.
    • Application.yaml에 sentry.exception-resolver-order: -2147483647를 설정하여 현재 모든 Exception, RuntimeException을 보내는 것으로 설정하였습니다.

*3. 추가 사항

  • sentry에서 제공하는 메시지 기능과 더불어 Alert 기능을 팀원들과 얘기해서 이를 어떻게 구체화
  • 모든 Exception에서 모니터링을 하고 있는데 exceptionHandler에서 캐치하지 못한 에러만 잡을지(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;
      
      }```
  • POST /images
    image image


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

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

image


📌 상품 예약

  • 상품의 상태를 RESERVED -> PUBLIC 으로 변경하기 위한 scheduler 를 구현.

  • 여러개의 인스턴스가 동일한 스케쥴 된 작업을 중복적으로 수행하지 않게하기 위해 shedlock 라이브러리를 사용해 작업을 동기화.

  • 1분마다 상품 오픈예약 정보가 들어있는 Reserve 테이블에서 현재시간 보다 이전인 예약 내역을 조회하고 상태변경

    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 단계까지 진행
  • gitlab container registry
    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

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

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

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

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

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

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

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

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

TODO

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