본문으로 건너뛰기

쇼핑몰에 AI 피팅룸을 만들었습니다 — API 키 없이 ChatGPT로

· 약 12분
AI builder

29CM 상품 페이지에서 확장 아이콘을 누르고 사진 한 장을 올리면, 그 옷을 입은 이미지가 상품 대표 이미지 자리에 그대로 들어갑니다. OpenAI API 키는 한 번도 발급하지 않았습니다. 이미 로그인해 둔 ChatGPT 탭에 확장이 프롬프트와 이미지를 대신 채워 넣는 방식이라, 키 관리도 사용량 정산도 없이 하루 만에 동작하는 프로토타입까지 갔습니다. 대신 언제 깨져도 이상하지 않은 방식이라, 확장 자체는 배포하지 않습니다.

배경

29CM 상품 상세 페이지 오른쪽에 열린 확장 팝업. 브랜드와 상품명이 자동으로 인식되어 있고 내 사진 업로드 칸이 보인다

쇼핑몰을 구경하다 문득 궁금해졌습니다. 이 옷을 입으면 어떻게 보일까. 요즘 이미지 모델은 사진 두 장만 주면 이런 걸 꽤 그럴듯하게 해내니, 상품 이미지와 인물 사진을 ChatGPT에 넣어봤습니다. 결과가 생각보다 재미있게 나왔습니다.

몇 번 더 해보니 과정이 슬슬 귀찮아졌습니다. 상품 이미지를 저장하고, 사진을 올리고, 프롬프트를 다시 쓰는 걸 옷 하나 볼 때마다 반복해야 합니다. 이쯤 되면 대개 같은 생각을 하게 됩니다. 이거 버튼 하나로 만들면 되지 않을까.

그냥 되는지 보고 싶었고, 되면 재미있을 것 같았습니다.

그래서 이 과정을 크롬 확장으로 접어 넣어봤습니다. 상품 페이지에서 아이콘 한 번, 사진 한 장이면 나머지는 확장이 하도록. 코드를 짜기 전에 먼저 한 일은 PRD 한 장을 쓰는 것이었습니다. 특히 만들지 않을 것(백엔드, 계정, 결제, 사진 저장)을 먼저 못 박아두니 하루 만에 동작하는 MVP까지 갈 수 있었습니다.

여기서 갈린 선택 — API를 쓸 것인가, 열려 있는 탭을 쓸 것인가

이런 기능을 만들 때 보통은 이미지 생성 API를 붙입니다. 그러면 키를 발급하고, 결제 수단을 등록하고, 키를 어디에 둘지 고민해야 합니다. 확장은 사용자 브라우저에서 도는 코드라 키를 넣으면 그대로 노출되니, 결국 중계 서버까지 세워야 합니다. 재미로 시작한 주말 실험에 인프라가 먼저 붙는 셈입니다.

방향을 튼 건 인영 님이 던진 한마디 때문이었습니다. 브라우저에 이미 로그인된 ChatGPT 탭이 열려 있는데, 그 탭을 그냥 쓰면 되지 않냐는 것이었습니다. 솔직히 처음 들었을 때는 그게 더 이상하게 느껴졌습니다. 정식으로 부르는 통로를 두고 왜 화면을 움직이나 싶었습니다.

그런데 실제로 만들어보니 이쪽이 훨씬 편했습니다. 확장이 하는 일은 세 가지로 줄어듭니다.

  1. 29CM 페이지에서 상품 정보와 이미지를 읽는다
  2. 로그인된 ChatGPT 탭을 찾아 프롬프트와 이미지 두 장을 채워 넣는다
  3. 생성된 결과 이미지를 다시 29CM 페이지로 가져와 적용한다

API 키도, 서버도, 별도 설정 파일도 없습니다. 사용자가 준비할 것은 "ChatGPT에 로그인되어 있을 것" 하나뿐입니다.

구현 흐름

1. 상품 정보는 화면이 아니라 JSON-LD에서 읽습니다

처음에는 상품명과 브랜드를 CSS 셀렉터로 긁으려고 했습니다. 그런데 29CM은 검색엔진용으로 application/ld+json 구조화 데이터를 이미 내려주고 있었습니다. 여기에는 sku, 상품명, 브랜드, 대표 이미지가 그대로 들어 있습니다.

셀렉터는 사이트가 디자인을 조금만 바꿔도 깨지지만, JSON-LD는 SEO 때문에 유지되는 데이터라 훨씬 오래갑니다. 그래서 JSON-LD를 1순위로 읽고, 실패하면 Open Graph 메타 태그로 떨어지도록 두 단계를 뒀습니다.

export function extract29cmProduct(document: Document, pageUrl: string): ProductData | null {
return extractFromJsonLd(document, pageUrl) ?? extractFromOpenGraph(document, pageUrl);
}

남의 사이트에서 무언가를 읽어야 할 때는, 화면을 긁기 전에 그 사이트가 이미 구조화해서 내놓은 데이터가 있는지부터 보는 편이 좋습니다.

2. 프롬프트는 짧게 시작해서 계속 길어졌습니다

이 글의 화면에 쓴 인물 사진은 실제 사람이 아니라 AI로 생성한 이미지입니다. 얼굴이 그대로 남는 실험이라 처음부터 가상 인물로 테스트했습니다. 조건은 전신이 나오고 옷 실루엣이 보이는 것, 그 정도면 충분했습니다.

이왕이면 팔을 내리고 있는 사진이 낫습니다. 팔짱을 끼면 상의가 가려져서 정작 보고 싶은 실루엣이 흐려집니다.

입력으로 사용한 AI 생성 인물 사진. 강변 산책로에서 찍은 듯한 전신 이미지로, 흰 가디건과 데님 반바지를 입고 있다

처음 프롬프트는 한 줄이었습니다. "이 사람이 이 옷을 입은 모습으로 만들어줘." 결과는 절반쯤 실패했습니다. 상품 이미지에 모델이 함께 찍혀 있으면 모델의 얼굴이 결과에 섞여 나오고, 이전 대화에 올렸던 사진을 참고하기도 했습니다.

그때마다 실패를 막는 문장을 한 줄씩 추가했고, 최종 프롬프트는 이렇게 길어졌습니다.

첫 번째 이미지를 직접 편집해서, 첫 번째 이미지 속 인물이 두 번째 이미지의 의류를 입은 모습으로 바꿔줘.
Use image 1 as the base canvas. Edit only the clothing area on the person in image 1.
이전 대화에 올라온 사진은 참고하지 마. 반드시 이번 메시지에 새로 첨부된 이미지들만 기준으로 사용해줘.
새로운 인물 사진을 만들지 마. 첫 번째 이미지의 인물, 얼굴, 헤어스타일, 자세, 카메라 구도는 최대한 유지해줘.
Do not copy the person from image 2. If image 2 contains a person or model, ignore that person's face, hair, pose, body shape, and background.
결과 이미지는 1:1 정방형 비율로 만들고, 인물이 중앙에 보이도록 충분한 여백을 남겨줘.

한글과 영어를 섞은 이유는 단순합니다. 핵심 제약(1번 이미지를 캔버스로 쓸 것, 2번 이미지의 사람은 무시할 것)을 영어로 한 번 더 적었을 때 결과가 눈에 띄게 안정적이었습니다. 프롬프트 튜닝은 결국 "실패한 결과를 보고 그 실패를 금지하는 문장을 추가하는 일"의 반복이었습니다.

3. ChatGPT 입력창을 채우되, 전송 버튼은 누르지 않습니다

입력창에 글자를 넣는 것부터 생각처럼 되지 않았습니다. ChatGPT의 입력창은 평범한 텍스트 상자가 아니라서, 글자를 그냥 밀어 넣으면 화면에는 보이는데 ChatGPT는 여전히 빈칸으로 압니다. 전송 버튼도 활성화되지 않습니다.

그래서 사람이 키보드로 친 것처럼 흉내 내야 합니다. 입력창에 커서를 두고, 기존 내용을 전부 선택하고, 브라우저의 텍스트 삽입 기능으로 글자를 넣은 뒤, 마지막으로 "방금 입력이 있었다"는 신호를 보냅니다. 이 네 단계를 모두 거쳐야 ChatGPT가 글이 들어왔다고 인정합니다.

composer.focus();
document.execCommand("selectAll", false);
document.execCommand("insertText", false, prompt);
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt }));

사진도 비슷합니다. 파일 선택 창을 대신 눌러줄 수는 없으니, 사용자가 파일을 끌어다 놓은 것과 같은 모양을 만들어 두 장을 한꺼번에 밀어 넣습니다. 첫 번째가 인물 사진, 두 번째가 상품 이미지입니다. 상품 이미지는 29CM 서버에서 바로 받아옵니다.

ChatGPT 입력창에 확장이 채워 넣은 프롬프트와 첨부된 이미지 두 장. 전송 버튼은 눌리지 않은 상태다

의도적으로 하지 않은 것이 하나 있습니다. 전송 버튼은 확장이 누르지 않습니다. 프롬프트와 이미지까지만 준비해두고, 실제로 보낼지는 사람이 확인하고 누르게 뒀습니다.

기술적으로는 어려운 일이 아닙니다. 전송 버튼을 찾아 click()을 부르는 코드 한 줄이면 팝업의 버튼 하나로 결과까지 자동으로 나오게 만들 수 있습니다. 그런데 그 한 줄을 넣는 순간 확장의 성격이 달라집니다. 사용자가 아직 화면을 보기도 전에 자기 계정으로 요청이 나가고, 프롬프트가 잘못 만들어졌거나 엉뚱한 이미지가 첨부됐어도 되돌릴 수 없습니다. 확장을 여러 번 누르면 그만큼의 요청이 그대로 서비스에 쌓입니다.

서비스 입장에서 봐도 다릅니다. 로그인한 사람이 자기 손으로 보내는 요청과, 프로그램이 사람 계정을 빌려 보내는 요청은 트래픽의 성격도 약관상 위치도 같지 않습니다. 앞엣것은 사용자의 행동이고, 뒤엣것은 봇의 행동입니다. 준비까지만 하고 멈추면 확장은 타이핑과 파일 첨부를 대신해주는 도구로 남지만, 전송까지 하면 사람 계정으로 남의 서비스를 두드리는 프로그램이 됩니다.

그래서 기준을 이렇게 잡았습니다. 되돌릴 수 있는 일(입력창 채우기, 이미지 첨부, 화면의 이미지 바꾸기)은 확장이 알아서 하고, 되돌릴 수 없는 일(요청 전송)은 사람이 마지막 클릭을 남긴다. 자동화 도구를 만들 때 어디까지 자동으로 할지 애매하면, 실행 취소가 되는 행동인지부터 보면 대체로 답이 나왔습니다.

4. 결과 이미지는 자동으로 고르지 않고, 버튼을 붙였습니다

원래 그리던 그림은 이랬습니다. ChatGPT에서 결과가 나오면 사람이 아무것도 누르지 않아도 29CM 탭의 상품 이미지가 알아서 바뀌는 것. 전송만 누르고 상품 페이지로 돌아가면 이미 입어본 모습이 떠 있는 흐름을 원했습니다. 그러려면 확장이 "생성이 끝났다"는 시점과 "이 그림이 결과다"를 스스로 알아내야 합니다. 여기서 제일 오래 헤맸습니다. 사람 눈에는 결과 이미지가 어느 것인지 한눈에 보이지만, 프로그램에게 ChatGPT 화면은 그냥 그림이 여러 장 놓인 페이지입니다. 방금 올린 사진도, 상품 이미지도, 이전 대화에 있던 그림도 전부 똑같은 자격으로 존재합니다.

처음에는 화면에서 가장 최근에 나타난 그림을 가져오게 했습니다. 그랬더니 29CM 상품 이미지 자리에 착용 결과가 아니라 버튼 아이콘 같은 게 올라와 있었습니다. 프로그램 입장에서는 화면 구석의 작은 아이콘도 그냥 그림 한 장이라, 결과 이미지와 구분할 이유가 없었던 것입니다.

그래서 크기로 거르기로 했습니다. 결과 이미지는 크게 나오니까요. 이번엔 입력창에 붙어 있는 업로드 썸네일이 걸려들었습니다. 화면에는 손톱만 하게 보이지만 원본 파일은 크기 때문에, 파일 크기로 재면 결과 이미지와 구분이 안 됩니다.

결국 기준을 "화면에 실제로 얼마나 크게 그려져 있는가"로 바꿨습니다. 파일이 아무리 커도 화면에서 작게 보이면 후보에서 뺍니다.

const rect = image.getBoundingClientRect();
const displayedArea = rect.width * rect.height;
if (displayedArea > 0) {
// 입력창의 업로드 썸네일은 원본이 커도 화면엔 작게 표시되므로 제외된다
return displayedArea >= 200 * 200;
}

그러다가 결국 자동 선택 자체를 포기했습니다. 대신 두 가지로 풀었습니다.

하나는 요청할 때마다 ChatGPT를 새 대화에서 시작하게 한 것입니다. 이미 열려 있던 대화를 그대로 쓰면 지난 그림들이 화면에 남아 있어 무엇이 이번 결과인지 헷갈리지만, 새 대화로 열면 화면에 있는 결과 이미지는 한 장뿐입니다. 골라야 할 대상 자체를 없앤 셈입니다.

다른 하나는 그 이미지에 29CM에 적용 버튼을 붙여 사람이 누르게 한 것입니다. 버튼은 화면 오른쪽 아래에 고정 패널로 띄웠습니다. 처음에는 결과 이미지 바로 아래에 붙이려 했지만, 남의 페이지 안에 요소를 끼워 넣으면 그 페이지의 레이아웃에 휘둘립니다. 화면 구석에 고정해두는 쪽이 어떤 상황에서도 눈에 띄었습니다.

이렇게 하니 확장이 할 일도 쉬워졌습니다. "정확히 이 한 장이 결과다"를 맞힐 필요 없이, 화면에 크게 그려진 그림에 버튼만 달아두면 됩니다. 정답을 맞히는 문제에서 판단을 사람에게 넘기는 문제로 바뀌자 오작동이 사라졌습니다.

한 장을 골라내던 코드는 지금도 남아 있고 테스트도 통과합니다. 다만 실제로는 쓰지 않습니다. 공들여 만든 걸 안 쓰는 건 아깝지만, 버튼 하나 더 두는 쪽이 나을 때가 있습니다.

ChatGPT가 생성한 착용 결과 이미지와, 화면 오른쪽 아래에 확장이 띄운 29CM 적용 버튼 패널

결과

29CM 상품 페이지에서 아이콘을 누르고 사진 한 장을 올리면, ChatGPT 탭이 열리고 프롬프트와 이미지 두 장이 채워집니다. 전송을 누르고 결과가 나오면 29CM에 적용 버튼을 누르고, 29CM 탭이 자동으로 앞으로 나오면서 대표 이미지가 착용 결과로 바뀝니다.

29CM 상품 페이지의 대표 이미지 자리가 셔츠를 입은 착용 결과 이미지로 바뀐 화면

이 글의 화면에 보이는 착용 이미지는 전부 AI가 만들어낸 것입니다. 브랜드가 촬영한 공식 착용컷이 아니고, 실제 옷의 핏이나 색감을 그대로 보여주지도 않습니다. 구매 판단의 근거로 쓸 만한 정확도는 아니고, 보면서 재미있어하는 용도에 가깝습니다.

적용한 결과는 내 피팅룸에 자동으로 쌓입니다. 나중에 썸네일을 누르면 그 상품 페이지로 바로 돌아갈 수 있습니다.

내 피팅룸 페이지. 착용 결과 세 개가 브랜드·상품명·저장 날짜와 함께 카드로 나열되어 있다

API 키, 결제 등록, 중계 서버, 배포 파이프라인은 하나도 없습니다. 확장 소스는 TypeScript 파일 23개에 테스트 66개 규모입니다.

이 확장을 쓰는 데는 사실상 돈이 들지 않습니다.

개인정보 쪽은 처음부터 선을 그었습니다. 업로드한 사진은 확장 저장소에 저장하지 않고, 피팅룸에 남는 것은 적용된 결과 이미지와 상품 링크뿐이며 전부 chrome.storage.local에만 있습니다. 외부로 나가는 데이터는 제가 직접 쓰는 ChatGPT 세션으로 가는 이미지 두 장이 전부입니다.

배운 점

코드는 대부분 AI가 썼습니다. 그래서 이 실험으로 확장 개발을 배웠다고는 못 하겠습니다. 대신 다른 게 남았습니다.

AI를 붙인다고 하면 늘 API부터 생각했는데, 다른 길이 있었습니다. 지금까지는 무언가에 AI 기능을 넣는다고 하면 자동으로 같은 그림을 그렸습니다. 키를 발급하고, 요청을 만들고, 비용을 계산하는 그림입니다. 그 앞단이 부담스러워서 시작을 미룬 아이디어도 있었습니다.

이번에는 이미 켜져 있는 ChatGPT 화면을 직접 움직여봤고, 그게 됐습니다. AI 서비스를 불러다 쓰는 대상이 아니라 이미 열려 있는 창으로 본 셈입니다. 처음에는 이 방식이 더 이상하게 느껴졌는데, 해보고 나니 키 발급하고 결제 붙이는 것보다 훨씬 편했습니다. 이번 실험에서 저한테 제일 재미있었던 건 이 부분입니다. 화면을 움직이는 방식이라면 애초에 API를 열어주지 않는 서비스에도 같은 접근을 해볼 수 있습니다.

"안 돼"라고만 하면 AI는 계속 엉뚱한 곳을 고칩니다. 가장 오래 붙잡은 문제는 ChatGPT에서 그림은 잘 만들어지는데 29CM에는 들어가지 않는 것이었습니다. 그대로 고쳐달라고 하면 그럴듯한 수정안이 끝없이 나옵니다. 이것도 바꿔보고 저것도 바꿔보지만 매번 빗나갑니다.

결국 화면 뒤에서 어디까지 진행되고 어디서 멈추는지를 단계마다 찍어보고 나서야 원인이 나왔습니다. 결과 이미지가 ChatGPT가 아니라 다른 주소에 보관돼 있어서, 확장이 그 주소를 열어볼 권한이 없었던 것이었습니다. 아무리 화면 쪽 코드를 고쳐봐야 나올 수 없는 답이었습니다. 고쳐달라고 하기 전에 어디서 멈췄는지부터 같이 확인하는 게 결국 빨랐습니다.

만들지 않을 것을 먼저 정하면 빨라집니다. 시작할 때 계정도, 결제도, 서버도, 사진 저장도 안 만들기로 못 박아두었습니다. 그 목록이 있으니 중간에 기능을 더 얹고 싶어질 때마다 브레이크가 걸렸고, 덕분에 하루 만에 돌아가는 것까지 나왔습니다.

막힌 곳은 대부분 코드가 아니라 규칙이었습니다. 크롬 확장은 특정 형식으로 만든 코드만 실행한다든가, 페이지 안에서 도는 코드는 확장의 저장 공간에 함부로 못 쓴다든가 하는 것들입니다. 이런 건 검색해서 알아내기도 어렵고, 틀려도 오류 메시지 없이 조용히 안 됩니다. 전부 AI가 찾아 고쳤고, 저는 "여기까지는 되는데 여기서부터 안 된다"를 알려주는 역할이었습니다.

완벽한 자동보다 버튼 하나가 나을 때가 있습니다. 결과 이미지를 자동으로 고르는 기능에 이 실험에서 가장 많은 시간을 썼고, 결국 버튼으로 바꾸니 문제가 사라졌습니다. 열 번 중 아홉 번 맞히는 자동화는 나머지 한 번을 계속 해명해야 하지만, 선택지를 보여주는 버튼은 그럴 일이 없습니다.

이 방식에는 유통기한이 있습니다. ChatGPT가 입력창 모양을 바꾸거나 29CM이 페이지 구조를 바꾸면 확장은 그날 바로 깨집니다. 남의 화면에 기대는 자동화는 언제든 무너진다고 전제하고, 깨졌을 때 사용자가 손으로 이어갈 수 있는 길은 남겨뒀습니다. 사진 첨부가 실패해도 프롬프트에 상품 이미지 주소가 함께 들어가 있어서, 그것만 복사해 쓰면 됩니다.

그래서 이건 배포용이 아니라 실험입니다. 남의 서비스 화면을 건드리는 확장은 스토어 정책과 각 서비스 약관의 경계에 있고, 제대로 만들려면 정식 API로 다시 세워야 합니다.

덧붙여 분명히 해두면, 이 실험은 29CM이나 OpenAI 어느 쪽과도 관계가 없습니다. 두 서비스가 공개한 화면을 개인 브라우저 안에서 읽고 쓴 개인 프로젝트이고, 두 회사의 검토나 승인을 받은 것이 아닙니다.

써보고 싶다면 프롬프트만 가져가세요

그래서 이 확장은 스토어에 올리지 않습니다. 다행히 이 실험에서 가져갈 만한 건 확장이 아니라 프롬프트 쪽입니다. 확장이 하는 일이라고는 사진 두 장을 대신 붙이고 긴 프롬프트를 대신 쳐주는 것뿐이라, 손으로 하면 결과는 똑같이 나옵니다.

ChatGPT를 새 대화로 열고, 사진 두 장과 함께 아래 프롬프트를 붙여 넣으면 됩니다. 첫 번째가 입어볼 사람 사진, 두 번째가 상품 이미지입니다. 순서가 바뀌면 엉뚱한 결과가 나오니 그것만 지키면 됩니다.

첫 번째 이미지를 직접 편집해서, 첫 번째 이미지 속 인물이 두 번째 이미지의 의류를 입은 모습으로 바꿔줘.
Use image 1 as the base canvas. Edit only the clothing area on the person in image 1.
이전 대화에 올라온 사진은 참고하지 마. 반드시 이번 메시지에 새로 첨부된 이미지들만 기준으로 사용해줘.
새로운 인물 사진을 만들지 마. 첫 번째 이미지의 인물, 얼굴, 헤어스타일, 자세, 카메라 구도는 최대한 유지해줘.
Do not copy the person from image 2. If image 2 contains a person or model, ignore that person's face, hair, pose, body shape, and background.
얼굴과 헤어스타일은 자연스럽게 유지하고, 옷의 색상, 실루엣, 소재감은 상품 이미지에 가깝게 반영해줘.
두 번째 이미지에 사람이 보이면 그 사람의 얼굴, 헤어스타일, 포즈, 체형, 배경은 무시하고 상하의, 신발, 액세서리 등 착장 아이템은 모두 참고해줘.
결과 이미지는 1:1 정방형 비율로 만들고, 인물이 중앙에 보이도록 충분한 여백을 남겨줘.

인물 사진은 팔을 내리고 상의가 온전히 보이는 것으로 고르면 결과가 안정적입니다.