본문 바로가기
프로그래밍/Web

중고마켓 기획에 이미지 검색 구현 후기(2)

by 물고기고기 2023. 10. 12.

기획(이전글) : https://lets-do-the-odessey.tistory.com/49

 

목차

  • 서버 구상도
  • DB 테이블을 어떻게 할 것인가?
  • 예제 코틀린 코드
  • 추가로 주의할 점

이전글에서는 중고마켓 기획에 어떻게 이미지 검색을 적용시킬건지 개요를 작성했다.

오늘은 실제 코드와 서버 구상도에 대해 얘기하고자 한다.

 

서버 구상도

이전 개요글에선 Google VisionAPI에 데이터셋을 저장하고 검색 api를 호출하면 요청을 받는 것을 확인했다.

그렇다면 이걸 api서버와 어떻게 결합하면 좋을까?

api서버에서 구글 open api server로 요청을 날리고 미리 DB와 매핑해놨던 key값이 도착하면 이를 처음 요청한 client에게 데이터와 함께 제공하면된다. 간단하게 순서를 말하자면

  1. client가 이미지로 검색을 요청한다.
  2. api server에서 google api server로 해당 이미지로 요청을 보낸다.
  3. google api server에서 받은 키 값을 DB에서 찾은 뒤 api server를 거쳐 client에게 제공한다.

와 같은 로직이다.

 

DB 테이블을 어떻게 할 것인가?

그렇다면 계속해서 말한 미리 DB와 매핑했다. 에서 DB와 매핑한다는 건 어떤 의미일까?

image-uri image-id product-set-id product-id product-category product-display-name labels
bounding-poly
gs://lookids-image-search/02.jpg   (1번에서 할당받은
프로젝트 set - id)
2 apparel-v2 sample02    
gs://lookids-image-search/03.jpg     3 apparel-v2 sample03    
gs://lookids-image-search/04.jpg     4 apparel-v2 sample04    
gs://lookids-image-search/05.jpg     5 apparel-v2 sample05    
gs://lookids-image-search/06.jpg     6 apparel-v2 sample06    

이전 개요글에서 이러한 데이터들로 제품세트를 제공하고 학습시킬 수 있다고 했다.

그렇다면 테이블에서는 학습시킨 데이터와 같은 id값의 값을 컬럼으로 가지고있으면 되지 않을까?

resell_product 
id images user_id product_name 그외에 다른 컬럼들 search_id

google api server에서 특정 id값을 받았다면 해당 값을 미리 resell_product 테이블에 저장해놓은 컬럼에서 찾아서 제공하면 된다.

이를 위해서는 제품을 미리 학습시켜둔것들을 일일히 손으로 매핑해줘도 되지만

배치를 통해 resell_product 에서 search_id는 원래 null값으로 두었다가 특정 시간이 되면 resell_product 를 주기적으로 제품세트에 추가해 학습시킨뒤 해당 id들을 search_id에 매핑시키는 방식으로 해도 된다.

(참고로 나는 토이프로젝트기 때문에 여기까진 작업하지 않았다.)

 

예제 코틀린 코드

  • controller
@RestController
@RequestMapping("/resell")
@Api(tags = ["리셀 마켓 api"])
class ResellMarketController(
    private val resellMarketService: ResellMarketService,
    private val googleCloudVisionService: GoogleCloudVisionService,
    private val closetService: ClosetService
) {

@Operation(summary = "리셀 마켓 이미지 검색")
    @PostMapping("/products/image-search")
    @ApiOperation("이미지 url로 보낼 것 -> 추후 수정 가능성 있음")
    suspend fun resellProductsImageSearch(
        @RequestPart("image") image: MultipartFile
    ): ResellProductsResponse {

        val imageBytes = image.bytes // 이미지를 바이트 배열로 변환하는 예시
        val imageSearchRequest = ImageSearchRequest(imageBytes)
        return resellMarketService.getImageSearchResult(imageSearchRequest)
    }}

 

  • service - 비즈니스 로직에 대한 코드
@Service
class ResellMarketService(
    private val resellMarketRepository: ResellMarketRepository,
    private val userRepository: UserRepository,
    private val googleCloudVisionService: GoogleCloudVisionService
) {

suspend fun getImageSearchResult(imageRequest: ImageSearchRequest): ResellProductsResponse {
        try {
            // 1. 이미지 구글 클라우드에 저장
            val targetImage = googleCloudVisionService.imageUpload(imageRequest.image, "vision")

            // 2. 해당 이미지를 요청 보내도록
            if(targetImage != null) {
                val searchTargetNums = googleCloudVisionService.annotateImage(targetImage)
                // 리스트라서 1,2,3 같은 값이 나올것임
                val searchResult = resellMarketRepository.findAllBySearchIdIn(searchTargetNums)

                val resellProductDTOs = searchResult.map { ResellProductDTO.fromResellProduct(it) }
                return ResellProductsResponse(resellProductResponse = resellProductDTOs)
            }

        } catch (e: ApiException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
        return ResellProductsResponse(resellProductResponse = listOf())
    }
    
    }

 

  • service - 구글 클라우드와 연결하는 코드
@Service
class GoogleCloudVisionService(
    @Value("\${cloud.gcp.storage.bucket}")
    private val bucketName: String,
    @Value("\${cloud.gcp.storage.credentials.location}")
    private val credentialsLocation: String
){

    private val storage: Storage
    private val endpoint: String

    init {
        val resource = ClassPathResource(credentialsLocation)
        val credentialsStream = resource.inputStream

        val credentials = GoogleCredentials.fromStream(credentialsStream)
            .createScoped(listOf("https://www.googleapis.com/auth/cloud-platform"))
        storage = StorageOptions.newBuilder().setCredentials(credentials).build().service
        endpoint = "https://vision.googleapis.com/v1/images:annotate?key=AIzaSyA0sgQHeEK1ukQ74nd9PqZ-fb0x_uTPqeY"
    }


    fun imageUpload(image: ByteArray,code: String?): String? {
        var objectName  = UUID.randomUUID().toString()

        if (code == "imageUrlSave") { // 일반 이미지 저장인경우

            // Generate a unique file name using UUID
            objectName += ".png" // assuming it's a PNG, change extension as needed

            val blobId = BlobId.of(bucketName, objectName)
            val blobInfo = BlobInfo.newBuilder(blobId).setContentType("image/png").build()

            storage.create(blobInfo, image)

            return "https://storage.googleapis.com/lookids-image-search/$objectName"
        }else if (code == "vision") { // vision검색인 경우

            // Generate a unique file name using UUID
            objectName += ".jpg" // assuming it's a PNG, change extension as needed

            val blobId = BlobId.of(bucketName, objectName)
            val blobInfo = BlobInfo.newBuilder(blobId).setContentType("image/jpg").build()

            storage.create(blobInfo, image)
            return "gs://$bucketName/$objectName"
        }else{
            return null
        }
    }

    suspend fun annotateImage(imageUrl: String): List<Long>{
        val requestBody = VisionRequest(
            requests = listOf(
                Request(
                    image = Image(source = ImageSource(imageUrl)),
                    features = listOf(Feature(type = "PRODUCT_SEARCH", maxResults = 3)),
                    imageContext = ImageContext(
                        productSearchParams = ProductSearchParams(
                            productSet = "projects/clean-authority-399607/locations/asia-east1/productSets/3e3c98c22d935a07",
                            productCategories = listOf("apparel-v2")
                        )
                    )
                )
            )
        )

        val client = HttpClient(Apache) {
            install(JsonFeature) {
                serializer = KotlinxSerializer()
            }
        }

        val responseString: String = client.post(endpoint) {
            contentType(ContentType.Application.Json)
            body = requestBody
        }

        // JSON 문자열을 Response 객체로 직접 파싱
        val json = Json { ignoreUnknownKeys = true }
        val jsonResponse = json.decodeFromString<Response>(responseString)

        val productIds: List<Long> = jsonResponse.responses
            .flatMap { it.productSearchResults.results }
            .map { it.product.name.split("/").last().toLong() }

        return productIds
    }


    @Serializable
    data class ImageSource(val gcsImageUri: String)

    @Serializable
    data class Image(val source: ImageSource)

    @Serializable
    data class Feature(val type: String, val maxResults: Int)

    @Serializable
    data class ProductSearchParams(val productSet: String, val productCategories: List<String>)

    @Serializable
    data class ImageContext(val productSearchParams: ProductSearchParams)

    @Serializable
    data class Request(val image: Image, val features: List<Feature>, val imageContext: ImageContext)

    @Serializable
    data class VisionRequest(val requests: List<Request>)

    @Serializable
    data class Product(
        val name: String,
        val displayName: String,
        val productCategory: String
    )

    @Serializable
    data class SearchResult(
        val product: Product,
        val score: Double,
        val image: String
    )

    @Serializable
    data class ProductSearchResults(
        val results: List<SearchResult>
    )

    @Serializable
    data class Response(
        val responses: List<ResponseItem>
    )

    @Serializable
    data class ResponseItem(
        val productSearchResults: ProductSearchResults
    )

}

 

aws S3말고 구글 클라우드 버킷을 이용했다. 사실 버킷 활용 패키지가 있는 것 같지만.. 빌드 에러가 나서 이 부분은 직접 작성했다. 그래서 코드가 좀 지저분 할 수 있다.

 

이렇게하면 이러한 출력값이 나온다.

{
    "resellProductResponse": [
        {
            "resellProductId": 123,
            "sellerNickname": "John",
            "productName": "Product 1",
            "productImage": "image_url1",
            "productPrice": 100,
            "userId": 456
        },
        {
            "resellProductId": 456,
            "sellerNickname": "Alice",
            "productName": "Product 2",
            "productImage": "image_url2",
            "productPrice": 200,
            "userId": 789
        }
    ]
}

 

추가로 주의할 점

사실 큰 이미지 파일을 다뤄본 프로젝트가 이게 처음인데 그러다보니 이미지 파일 크기 오류가 떴다..

그렇게 큰일은 아니지만 모른다면 헤맬수 있으니 추가해둔다.

이러한 오류인데

org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field styleImage exceeds its maximum permitted size of 1048576 bytes.
	at org.apache.tomcat.util.http.fileupload.impl.FileItemStreamImpl$1.raiseError(FileItemStreamImpl.java:117) ~[tomcat-embed-core-9.0.68.jar:9.0.68]
	at org.apache.tomcat.util.http.fileupload.util.LimitedInputStream.checkLimit(LimitedInputStream.java:76) ~[tomcat-embed-core-9.0.68.jar:9.0.68]
	at org.apache.tomcat.util.http.fileupload.util.LimitedInputStream.read(LimitedInputStream.java:135) ~[tomcat-embed-core-9.0.68.jar:9.0.68]

 

application.yml 에 이런식으로 파일 사이즈를 지정해주면 파일 관련 에러가 안난다.

spring:
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 5MB

 

파일 사이즈가 큰 건을 다루는 서비스라면 위의 설정을 미리 추가해두면 좋다.


사실 해커톤이 끝난지 2주정도가 지났고 수상하지 못해 아쉽지만.. 그래도 일주일동안 재밌게 코딩했다.

한편으론 코틀린을 처음 사용해봐서 코드가 지저분하기도하고  open vision api도 잘못활용했을 수도 있지만 두개나 도전해봤으니 후회없다 ^ ㅂ^b

나중에 시간이 되면 resell_product에 새로 저장된 데이터들을 배치로 이미지검색에 추가 학습하는걸 만들어보고싶다.

댓글