Mini Project 4 04

AI 표지 생성, 화면 미리보기부터 저장까지: 프론트엔드 데이터 흐름 이해하기

AI 표지 생성이라고 하면 모델이 그림을 만든다는 결과만 보인다. 하지만 실제 프로젝트에서는 API key 입력, prompt 구성, b64_json 변환, 미리보기, 저장 버튼의 흐름을 따라야 한다.

근거 · 교안 OpenAI 표지 생성/frontend 구현

AI 표지 생성, 화면 미리보기부터 저장까지: 프론트엔드 데이터 흐름 이해하기 대표 이미지

1장: 내 아이디어가 AI 프롬프트로 바뀌는 순간

솔라의 손끝이 노트북 화면 위 한 문장에서 멈췄다. 프로젝트 기능 명세를 정리한 문서였다.

"AI 표지 기능은 React에서 OpenAI Images API를 호출하고..."

‘호출한다.’ 아는 단어였다. React 컴포넌트가 다른 함수를 부를 때, 웹 브라우저가 서버에 데이터를 요청할 때 쓰는 말. 하지만 지금은 그 단어가 투명한 벽처럼 느껴졌다. 벽 너머 풍경은 어렴풋이 보이는데, 다가갈 길이 보이지 않았다.

“그냥 AI가 뚝딱 그려주는 게 아니었나?”

솔라의 혼잣말에, 맞은편에서 조용히 책을 읽던 루나가 고개를 들었다. 솔라는 화면을 가리켰다.

“AI 표지 생성 기능 말이야. 사용자가 책 제목이랑 저자 이름, 원하는 스타일 몇 개 입력하고 ‘생성하기’ 버튼을 누르잖아. 그럼 잠시 뒤에 멋진 표지 이미지들이 나타나고. 근데 그 중간에 ‘API를 호출한다’는 과정이 있다는 건 알겠는데, 내가 입력한 글자들이 어떻게 ‘호출’이라는 동작으로 이어지는지 모르겠어. 그냥… 마법 같아.”

솔라의 말에는 ‘나는 개발자인데, 이게 왜 마법처럼 느껴지지?’ 하는 자책이 살짝 묻어났다. 단순히 결과만 아는 사용자와, 과정을 이해하는 개발자 사이의 경계에서 길을 잃은 기분이었다.

루나는 책을 덮고 잠시 생각하더니, 옆에 있던 메모지와 펜을 솔라 쪽으로 밀어주었다.

“마법의 주문을 우리가 한번 만들어볼까? 솔라, 네가 지금 책 표지를 AI에게 맡기려는 사용자야. 어떤 책의 표지를 만들고 싶어?”

“음… 윤동주 시인의 ‘하늘과 바람과 별과 시’로 해볼래.”

“좋아. 그럼 저자는 윤동주. 원하는 그림 스타일은?”

“서정적이고, 밤하늘의 별이 반짝이는 느낌이었으면 좋겠어.”

솔라는 기대에 찬 얼굴로 루나를 바라봤다. 이제 루나가 ‘마법’의 비밀을 알려줄 차례라고 생각하는 듯했다. 하지만 루나는 턱을 괸 채 솔라가 쥔 펜을 가리킬 뿐이었다.

“자, 이제 네가 coverService.js 파일이 됐다고 상상해봐. 사용자인 솔라가 입력한 ‘하늘과 바람과 별과 시’, ‘윤동주’, ‘서정적인 밤하늘’이라는 정보를 받았어. 이제 이걸 들고 OpenAI라는 까다로운 마법사를 찾아가야 해. 마법사는 그냥 평범한 말은 못 알아들어. 아주 엄격한 양식에 맞춰서 요청서를 써 가야만 해.”

“요청서?”

“응. 컴퓨터끼리의 대화는 대부분 정해진 양식이 있으니까. 택배 보낼 때 상자에 받는 사람, 주소, 물품명을 정해진 칸에 써넣는 것처럼.”

루나는 메모지에 네모난 상자를 하나 그리고, 그 안에 몇 개의 항목을 적을 빈칸을 만들었다.

“제일 중요한 건, 마법사에게 ‘무엇을’ 그려달라고 할지 아주 구체적으로 알려주는 설명서야. 이걸 ‘프롬프트(prompt)‘라고 불러. 우리가 가진 정보로 프롬프트를 만들어볼까?”

솔라는 잠시 고민하더니 펜을 들어 적기 시작했다.

"윤동주의 시집 '하늘과 바람과 별과 시'를 위한 책 표지. 서정적이고 반짝이는 밤하늘이 담겨 있으면 좋겠다."

“좋아. 아주 구체적인 주문이야.” 루나가 말했다. “다음으로, 마법사에게 몇 개의 그림을 그려주길 원하는지 궁금해할 거야. 선택지를 여러 개 보고 싶을 수도 있잖아.”

“아, 개수도 정할 수 있구나. 그럼 일단 4개 정도 받아볼래.”

솔라는 메모지에 n: 4 라고 덧붙였다.

“이미지 크기는 어때? 정사각형으로 할까, 아니면 직사각형으로 할까? 이것도 마법사가 미리 정해놓은 규격 중에 골라야 해.”

“책 표지니까… 정사각형이 예쁠 것 같아. 1024x1024 픽셀로.”

솔라는 size: "1024x1024" 라고 적었다.

“그리고 아주 중요한 것. 어떤 마법사를 쓸지, 즉 어떤 그림 모델을 사용할지 명시해야 해. 우리는 gpt-image-2 모델을 쓰기로 했어.” 루나가 덧붙였다.

솔라는 고개를 끄덕이며 model: "gpt-image-2"를 추가했다.

“이제 다 된 거 아니야? 뭘 더 알려줘야 해?”

“거의 다 왔어. 마지막은, 그림을 어떤 형태로 돌려받을지 정하는 거야. 완성된 그림 파일이 있는 인터넷 주소(URL)로 받을 수도 있고, 아니면… 그림 데이터 자체를 글자로 길게 풀어서 받을 수도 있어.”

“그림 데이터 자체?”

“응. 여기선 b64_json이라는 방식을 쓴다고 해두자.”

루나가 말하자, 솔라는 메모지에 response_format: "b64_json" 이라고 조심스럽게 적었다. 그리고 자신이 완성한 요청서를 가만히 내려다보았다.

prompt: 윤동주의 시집을 위한 서정적인 밤하늘 표지
model: gpt-image-2
size: 1024x1536
quality: medium

그 순간, 솔라의 머릿속에서 흩어져 있던 조각들이 맞춰졌다.

“아! 알겠다! 내가 AiCoverPanel 화면에 입력했던 단어들이 그냥 AI에게 날아가는 게 아니었어. coverService.js 같은 중간 역할의 코드가 내 아이디어를 이런… 공식적인 ‘요청서’ 형식으로 재구성해서 OpenAI API에 전달하는 거구나. ‘API를 호출한다’는 말의 진짜 의미가 바로 이 요청서를 보내는 행위였던 거야!”

더 이상 마법이 아니었다. 사용자의 불분명한 아이디어를 기계가 이해할 수 있는 명확한 구조로 번역하는, 논리적인 절차였다. 뿌옇던 벽이 투명해지면서, 그 너머의 길이 선명하게 보이기 시작했다.

문득 솔라는 자신이 마지막에 적었던 낯선 단어에 시선이 머물렀다.

“언니, 그런데 b64_json 이건 대체 뭐야? URL이라면 그냥 이미지 주소일 텐데, 이건 글자 덩어리라며. 글자가 어떻게 다시 우리가 볼 수 있는 그림으로 바뀌는 거지?”

솔라의 질문에, 루나는 빙그레 웃었다. 첫 번째 주문의 비밀을 푼 동생이, 이제 막 두 번째 마법의 문 앞에 다다른 참이었다.

2장: b64_json, 화면에 이미지가 되기까지

루나는 솔라의 질문에 바로 대답하는 대신, 자신의 노트북을 끌어당겼다. 간단한 텍스트 편집기 창을 하나 띄우더니, 바탕화면에 있던 아주 작은 검은색 사각형 이미지 파일(PNG) 하나를 편집기 창 안으로 휙 끌어넣었다.

순식간에 화면은 의미를 알 수 없는 기호와 깨진 글자들로 가득 찼다. ‰PNG... IHDR... 로 시작하는 알아볼 수 없는 문자열의 행진. 솔라는 눈을 가늘게 뜨고 화면에 얼굴을 가까이 가져갔다. 이건 그림이 아니라, 마치 암호문 같았다.

“이게… 뭐야?”

“이게 방금 그 검은 사각형 이미지야.”

루나는 모니터에 가득 찬 기호들을 가리키며 담담하게 말했다. “컴퓨터가 이미지를 볼 때, 실제로는 이런 데이터의 연속을 읽는 거야. 0과 1로 이루어진 바이너리 데이터를 텍스트 형식으로 억지로 표현한 거지. 우리 눈에는 그냥 암호처럼 보일 뿐이고. 이걸 복사해서 코드에 붙여 넣거나 이메일로 보내기엔 아주 불편하겠지?”

솔라는 고개를 끄덕였다. 이미지란 결국 데이터 덩어리라는 사실을 새삼 깨달았다. 우리가 보는 예쁜 그림의 속살은 이렇게 복잡하고 다루기 힘든 모습이었던 것이다.

“바로 이럴 때 쓰는 기술이 Base64 인코딩이야.”

루나는 새로운 메모장을 열어, 방금 전의 알아볼 수 없던 문자열보다 훨씬 정돈된, 알파벳과 숫자로만 이루어진 짧은 텍스트를 붙여 넣었다.

iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=

“이것도 똑같은 그 검은 사각형 이미지 데이터야. 바이너리 데이터를 웹이나 텍스트 기반 환경에서 안전하게 다룰 수 있도록, 알파벳 대소문자, 숫자, 그리고 몇몇 특수 기호(+)와 (/)를 사용해서 텍스트로 변환한 거지. OpenAI API가 b64_json 형식으로 이미지를 준다는 건, 바로 이런 Base64로 인코딩된 텍스트 덩어리를 준다는 뜻이야.”

“아하! 그럼 API가 이 텍스트를 주면, 브라우저가 이걸 알아서 그림으로 바꿔서 보여주는 거구나!”

솔라는 유레카를 외치듯 말했다. 이제 모든 게 풀린 것 같았다. API가 암호 같은 텍스트를 주면, 똑똑한 브라우저가 짠하고 그림으로 해석해준다. 단순하고 명쾌했다. 하지만 솔라의 그 판단은, 아직 완성되지 않은 다리의 마지막 조각을 빠뜨리고 있었다.

루나는 고개를 저었다. “거의 맞았지만, 아주 중요한 한 단계가 빠졌어. 이 텍스트 덩어리만 덜렁 던져주면, 브라우저는 이게 대체 뭔지 알 수가 없어. 그냥 의미 없는 글자들의 나열일 뿐이지. 이게 이미지인지, PDF 파일인지, 아니면 그냥 아무 말인지 어떻게 알겠어?”

루나는 메모지에 HTML의 이미지 태그를 하나 적었다.

<img src="...?">

“우리가 보통 이미지를 보여줄 땐 src 속성에 https://.../image.png 같은 파일 주소를 넣잖아. 브라우저는 ‘https’를 보고 ‘아, 인터넷에서 파일을 가져와야겠구나’ 하고 알아채지. 그럼 이런 텍스트 덩어리를 보여줄 땐 뭐라고 알려줘야 할까?”

루나는 물음표 자리에 새로운 ‘주소’를 써 내려가기 시작했다. 마치 택배 상자에 송장을 붙이듯, 텍스트 데이터에 정보를 덧붙이는 과정이었다.

  1. data: : “이건 외부 파일이 아니라, 데이터 자체를 담고 있다는 신호야.”
  2. image/png; : “데이터의 종류는 PNG 이미지라는 설명서(MIME 타입)고.”
  3. base64, : “데이터는 Base64 방식으로 인코딩되어 있다는 안내문이지.”
  4. iVBORw0KG... : “그리고 여기에 진짜 이미지 데이터인 Base64 텍스트가 오는 거야.”

루나는 이 네 부분을 조합한 완성된 주소를 보여주며 말했다. “이렇게 만든 전체 문자열을 우리는 데이터 URL(Data URL)이라고 불러. 말 그대로 이미지 데이터를 직접 담고 있는 특별한 주소인 셈이지. 이걸 웹 브라우저가 아주 잘 이해해.”

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">

그것을 본 순간, 솔라의 머릿속에서 마지막 퍼즐 조각이 ‘찰칵’하고 맞춰졌다.

“알겠다! 이제 완벽하게 알겠어! OpenAI API가 주는 b64_json 값은 저 긴 텍스트 덩어리, 즉 ‘내용물’이었던 거야. 그걸 그대로는 못 쓰고, coverService.jsdata:image/png;base64, 라는 ‘포장지’를 씌워서 완전한 ‘데이터 URL’이라는 제품으로 만드는 거구나! 그리고 AiCoverPanel 컴포넌트는 이 완성된 데이터 URL을 img 태그의 src에 넣어주기만 하면, 브라우저가 비로소 이미지를 화면에 그려줄 수 있게 되는 거고.”

솔라는 더 이상 b64_json이 화면에 바로 그림이 될 수 있다고 생각하지 않았다. 그것은 그림의 ‘재료’일 뿐이며, 반드시 ‘데이터 URL’이라는 정해진 규칙에 따라 가공되어야만 비로소 웹 브라우저가 이해할 수 있는 시각적 정보가 된다는 사실을 깨달았다. 암호문 해독이 아니라, 정교한 조립 과정이었다.

생성된 표지들이 화면에 나란히 미리보기로 떠 있는 모습이 눈앞에 그려졌다. 솔라는 만족스러운 미소를 지었다. 하지만 그 미소는 이내 새로운 궁금증으로 바뀌었다.

“언니, 그럼 이 미리보기 이미지는 이제 내 브라우저에만 존재하는 거잖아. 이 데이터 URL은 엄청 길고 복잡한데, 내가 ‘저장’ 버튼을 누르면 이 긴 텍스트가 우리 책 데이터베이스에 그대로 저장되는 거야? 왠지 비효율적인 것 같은데… 이 임시 이미지는 어떻게 내 진짜 책의 ‘영구적인’ 표지가 되는 거지?”

두 번째 마법의 비밀을 푼 솔라의 눈은, 이제 마지막 관문을 향하고 있었다.

3장: 미리 본 표지, 내 책에 저장되기까지

솔라의 시선은 이전 장에서 완성했던 메모지의 마지막 줄에 머물러 있었다. data:image/png;base64,... 로 시작하는 긴 데이터 URL. 그 옆에는 가상의 미리보기 이미지 네 개와, 각 이미지 아래에 작은 ‘저장’ 버튼이 그려져 있었다. 솔라가 상상으로 그려본 AiCoverPanel의 모습이었다.

임시 데이터가 눈에 보이는 이미지로 바뀌는 과정은 이제 이해했다. 하지만 그 다음, ‘저장’ 버튼을 누른 후의 경로가 여전히 안갯속이었다. 사용자의 선택이 어떻게 ‘내 책’의 데이터가 되어 영구히 남게 되는 걸까?

“언니, 그럼 내가 여기서 마음에 드는 이미지의 ‘저장’ 버튼을 누르면… 이 길고 긴 데이터 URL 문자열 전체가 우리 db.json 파일 안에 있는 책 정보에 그대로 들어가는 거야?”

솔라는 고개를 갸웃하며 말했다.

“데이터베이스에 이렇게 긴 텍스트를 저장하는 게 맞아? 사진 파일 하나를 저장하는데 이렇게 글자가 길어야 한다니. 뭔가… 비효율적이고 지저분해 보여.”

이전까지 마법처럼 보였던 과정이, 이제는 어딘가 투박한 망치질처럼 느껴졌다. 가장 우아하지 않은 방법으로 데이터를 욱여넣는 것 같았다.

루나는 솔라가 그려놓은 ‘저장’ 버튼에 동그라미를 치며 말했다.

“그럴듯한 추측이야. 그게 왜 비효율적이라고 생각해?”

“음… 그냥 봐도 너무 길잖아! 책 한 권의 정보에 제목, 저자 같은 짧은 텍스트만 있을 줄 알았는데, 표지 이미지 하나 때문에 수천, 수만 자의 텍스트가 들어간다면 데이터 파일이 금방 무거워질 거고, 관리하기도 힘들어질 테니까.”

“좋은 지적이야. 보통은 그렇게 하지 않아. 이미지 파일을 어딘가에 업로드하고, 그 파일의 짧은 주소(URL)만 저장하는 게 일반적이지. 하지만 지금 우리 프로젝트처럼 파일을 저장할 별도의 서버가 없다면, 데이터 URL을 저장하는 것도 하나의 방법이 될 수 있어.”

루나는 말을 이었다. “중요한 건 ‘무엇을’ 저장하느냐보다 ‘어떻게’ 그 저장 과정이 일어나는지 이해하는 거야. 사용자가 선택한 그 긴 데이터 URL을 가지고, ‘저장’ 버튼이 눌리는 순간부터 다시 따라가 보자.”

루나는 펜을 들어 AiCoverPanel이라고 적힌 상자에서 화살표를 하나 길게 뽑아냈다.

“이 버튼을 누르면, AiCoverPanel 컴포넌트는 어떤 정보를 가지고 누구를 찾아가야 할까? OpenAI를 다시 부를 필요는 없겠지.”

“그렇지. 이미 이미지는 만들어졌으니까.” 솔라는 즉시 답했다. 잠시 생각에 잠겼던 그녀의 눈이 반짝였다. “아! 책 데이터를 수정하는 거니까, 책 정보를 전문적으로 다루는 다른 조력자가 필요하겠구나! bookService.js 같은 거!”

“바로 그거야.”

루나는 화살표 끝에 bookService.js라는 상자를 그렸다.

솔라는 신이 나서 설명을 이어갔다. “AiCoverPanel은 자기가 어떤 책(bookId)의 표지를 만들고 있었는지, 그리고 내가 어떤 데이터 URL을 선택했는지 알고 있을 거야. 그럼 그 두 정보를 bookService.js에게 넘겨주면서 ‘이 책의 표지를 이걸로 바꿔줘!’ 하고 부탁하겠지. updateBookCover(bookId, selectedDataUrl) 이런 식으로!”

“그리고 bookService.js는?”

“그 친구는 우리 데이터베이스 역할을 하는 json-server와 대화하는 방법을 알잖아. 서버에 ‘이 아이디를 가진 책을 찾아서, coverImageUrl 필드의 값을 이 새로운 데이터 URL로 갱신해 줘’라는 공식적인 요청(PATCH /books/{id})을 보내는 거지.”

솔라는 자기가 bookService가 된 것처럼 말하며, 루나가 그린 다이어그램의 마지막 빈 곳을 채워 넣었다. bookService.js 상자에서 ‘DB (json-server)‘라고 적힌 마지막 상자로 향하는 화살표.

AiCoverPanel -> bookService.js -> DB

모든 연결이 명확하게 드러나는 순간이었다. ‘저장’ 버튼 뒤에 숨어 있던 흐름은 더 이상 마법도, 투박한 망치질도 아니었다. 각자 명확한 역할을 가진 코드 조각들이 순서대로 정보를 주고받는, 잘 짜인 협업 과정이었다.

“이제 알겠어. 저장 버튼을 누르는 건 결국 또 다른 API 호출이었어. 다만 이번엔 외부의 OpenAI가 아니라, 우리 내부의 데이터 서버를 향한 호출이었을 뿐이구나.”

솔라는 완성된 데이터 흐름도를 보며 중얼거렸다. 그제야 처음 봤던 그 문장, ’…책 데이터 갱신으로 연결하는 frontend 경험이다’라는 말의 의미가 온전히 와닿았다. 사용자의 클릭 한 번이 프론트엔드 세계 안에서 어떻게 연쇄적인 데이터의 흐름을 만들어내고, 결국 눈에 보이는 변화와 영구적인 기록으로 이어지는지, 그 전체적인 경험의 설계. 그것이 바로 개발의 본질이었다.

솔라는 만족스럽게 고개를 끄덕이더니, 문득 생각났다는 듯 펜을 들었다. 그녀는 방금 그린 다이어그램 옆에 새로운 상자를 그리기 시작했다. ‘AI 태그 생성’이라고 제목을 붙였다.

“언니, 그럼 만약에 표지뿐만 아니라 책 내용 요약을 AI한테 보내서 관련 태그를 추천받는 기능을 만든다고 하면…”

그녀는 망설임 없이 새로운 데이터의 흐름을 그려나갔다.

  1. BookDetail 컴포넌트에서 ‘태그 생성’ 버튼 클릭.
  2. tagService.js (새로운 조력자!)가 OpenAI API 호출.
  3. 응답으로 받은 태그들을 화면에 미리보기로 표시.
  4. 사용자가 ‘태그 저장’ 버튼 클릭.
  5. bookService.jsupdateBookTags 함수 호출.
  6. bookService.js가 데이터베이스에 PATCH 요청.

그림을 완성한 솔라는 활짝 웃었다. “이 패턴, 이제 완전히 내 것이 된 것 같아.”

처음 ‘API를 호출한다’는 낯선 문장에서 시작된 여정이, 이제는 스스로 새로운 기능을 설계하고 그 데이터의 길을 그려낼 수 있는 단단한 자신감으로 끝을 맺고 있었다. 더 이상 솔라에게 프론트엔드에서의 데이터 흐름은 보이지 않는 마법이 아니었다. 직접 설계하고 예측할 수 있는, 명확한 길이었다.