Mini Project 5 06

AI 표지 및 태그 저장 경계 이해하기: 4차와 5차의 변화

AI 표지 기능이 4차에도 있고 5차에도 있어 무엇이 새로워졌는지 헷갈린다. 생성 자체와 backend 저장 경계를 나누어 보아야 한다.

근거 · 교안 OpenAI 표지 저장 경계 파트

AI 표지 및 태그 저장 경계 이해하기: 4차와 5차의 변화 대표 이미지

1장: 4차 이터레이션의 AI 표지: json-server의 역할

솔라는 모니터에 떠 있는 프로젝트 기록을 위아래로 스크롤하며 미간을 찌푸렸다. 두 개의 프로젝트 폴더, ‘4차’와 ‘5차’가 나란히 보였다. 그녀의 시선은 각 폴더의 기능 명세서에 적힌 한 줄에 머물렀다. ‘AI 표지 생성 기능’.

“언니, 이거 좀 이상해.”

노트북으로 무언가에 몰두하던 루나가 고개를 들었다. 솔라는 의자를 돌려 루나를 향해 말했다.

“5차 프로젝트에서 AI 표지 기능을 새로 만들었다고 생각했는데, 내가 보니까 4차 미니프로젝트 때도 ‘AI 표지 생성’ 기능이 있었잖아. 이름도 똑같은데, 대체 뭐가 달라진 거야? 그냥 똑같은 거 한 번 더 만든 느낌인데.”

솔라의 말에는 기능의 이름이 같으니 내부 구현도 비슷할 것이라는 추측이 묻어났다. 겉보기에는 아무것도 변하지 않은 것 같았다.

루나는 자리에서 일어나 솔라의 뒤로 다가와 모니터를 들여다봤다. 그리고는 솔라의 질문에 바로 답하는 대신, 다른 방향으로 화제를 돌렸다.

“그 기능의 이름이 같다는 점에 주목했구나. 좋아. 그럼 4차 프로젝트 때로 잠시 돌아가 볼까? 사용자가 화면에서 ‘AI 표지 생성’ 버튼을 눌렀을 때, 그 뒤에서 무슨 일이 일어났지? 특히, 그렇게 만들어진 표지 이미지 URL과 태그들은 최종적으로 어디에 가서 ‘저장’되었을까?”

“음…” 솔라는 잠시 생각에 잠겼다. 기억을 더듬는 듯 허공을 바라보던 그녀가 입을 열었다. “프론트엔드 코드가 OpenAI 같은 AI 서비스에 요청을 보내서 이미지랑 태그 묶음을 받아왔고… 그걸 우리 ‘서버’에 업데이트해달라고 요청을 보냈지. 이 책 정보를 이걸로 바꿔달라고.”

“맞아. 그 ‘서버’의 정체가 뭐였지?” 루나의 차분한 질문이 핵심을 파고들었다.

그 순간 솔라의 눈이 동그래졌다. 마치 잊고 있던 사실이 퍼뜩 떠오른 사람처럼.

“아! json-server! 맞다, 그때는 진짜 백엔드 애플리케이션이 아니라, 임시로 json-server를 세워서 쓰고 있었지.”

스스로 내뱉은 말에서 실마리를 찾은 듯, 솔라의 목소리에 활기가 돌기 시작했다. 그녀는 이제 루나를 보지 않고 화면 속의 4차 프로젝트 구조를 떠올리며 말을 이었다.

“그러니까 json-server는… 그냥 우리가 API 요청으로 JSON 데이터를 보내주면, 그걸 db.json이라는 파일에 그대로 덮어쓰기만 하는 단순한 도구였어. 데이터가 유효한지, 어떤 규칙을 따라야 하는지 전혀 신경 쓰지 않았지. 그냥 받아 적기만 하는 메모장 같은 거였네.”

솔라는 잠시 말을 멈췄다. 4차 프로젝트의 ‘서버’가 가짜였다는 사실을 새삼 깨달았다. 그건 진짜 시스템이 아니라, 프론트엔드 개발을 위해 임시로 세워 둔 허수아비에 가까웠다.

“바로 그거야.” 루나가 조용히 동의했다. “4차 시스템에서 AI 표지 정보의 ‘저장’은, 프론트엔드가 모든 걸 결정해서 건네주면 json-server가 그저 파일에 받아 적는 흐름이었어. 책임의 주체는 프론트엔드였고, 저장소는 아무런 의견이 없었지. 그럼 5차에서는 그 저장 요청을 누가 받지?”

“5차에서는… 우리가 만든 Spring Boot 백엔드가 받지. PATCH /books/{id}/cover라는 정식 API 엔드포인트를 통해서.”

솔라는 대답을 하며 스스로 놀란 듯 잠시 말을 멈췄다. 그리고 깊은숨을 내쉬었다. 두 시스템의 결정적인 차이가 안개처럼 흩어지는 기분이었다.

“이제 알겠다. 기능의 이름은 같지만, 그 기능이 상호작용하는 대상의 정체성이 완전히 달라진 거구나. 4차에서는 AI가 만든 데이터가 그저 파일에 ‘기록’되는 수준이었다면, 5차에서는 그 데이터가 ‘Book’이라는 엄연한 도메인 모델을 관리하는 백엔드 시스템에게 정식으로 제출되고, 검증을 거쳐 데이터베이스에 ‘저장’되는 절차를 밟는 거네. 이건 그냥 구현이 바뀐 게 아니라, 책임의 경계가 통째로 바뀐 거였어.”

솔라는 의자에 등을 기댔다. 4차와 5차를 가르는 선명한 대조점이 머릿속에 그려졌다. 하나는 임시 메모장에 기록하는 행위였고, 다른 하나는 도서관 사서에게 정식으로 도서를 등록하는 절차와 같았다. 이제 ‘AI 표지 생성’이라는 같은 이름의 기능이 전혀 다르게 보였다.

혼란이 해결되자, 새로운 궁금증이 고개를 들었다.

“좋아, 데이터가 어디에 어떻게 저장되는지에 대한 경계는 확실히 이해했어. 그럼 이제 이게 궁금한데. 5차에서 프론트엔드가 AI한테 표지랑 태그를 만들어달라고 요청하는 그 부분 말이야. 그 생성 과정 자체는 4차랑 똑같은 방식일까, 아니면 거기도 뭔가 달라진 게 있을까?“

2장: 5차 프론트엔드의 AI 생성: OpenAI와의 대화

솔라의 질문에 루나는 대답 대신 자리에서 일어나 솔라의 책상 옆에 놓인 작은 화이트보드로 향했다. 지난번 대화에서 그려둔 4차와 5차 시스템의 흐름도 옆에, 루나는 새로운 상자 두 개를 나란히 그렸다. 그리고 각각 ‘AI 이미지 생성’과 ‘AI 태그 생성’이라고 적었다.

솔라는 의자를 돌려 화이트보드를 바라보았다. 언니가 무엇을 하려는지 명확했다. 4차와 5차의 ‘저장’ 경계를 구분했듯, 이제 ‘생성’의 책임 소재를 밝혀낼 차례였다. 솔라는 자신의 생각을 먼저 꺼내놓았다.

“음, 내 생각은 이래. 사용자가 버튼을 누르면 프론트엔드가 우리 백엔드 서버에 ‘표지 만들어줘’라고 요청할 거야. 그럼 우리 백엔드에서 OpenAI API를 호출해 결과를 받아오고, 이걸 프론트엔드에 돌려주는 거지. API 키 같은 민감한 정보는 백엔드에 있는 게 안전하니까. 아마 4차 때도 그렇게 하지 않았을까?”

백엔드가 AI 생성을 중개할 것이라는 솔라의 추측은 꽤 그럴듯했다. 하지만 루나는 긍정도 부정도 하지 않은 채, 매직펜을 솔라에게 건넸다.

“그럴듯한 가설이네. 그럼 그 흐름을 직접 따라가 보자. 개발자 도구의 네트워크 탭을 열고 ‘AI 표지 생성’ 버튼을 누른다고 상상해 봐. 화면에 어떤 일이 일어날까? 우리 브라우저가 어느 서버 주소로 요청을 보내는 게 보일까? 우리 백엔드 주소일까, 아니면 전혀 다른 주소일까?”

“네트워크 탭이라…”

솔라는 눈을 감고 상상 속의 개발자 콘솔을 열었다. 수많은 네트워크 요청 목록이 촤라락 스쳐 지나갔다. 그중 ‘Generate’ 버튼을 누르는 순간 새로 생겨날 단 하나의 요청에 집중했다. 그 요청의 목적지는 어디일까? 솔라는 눈을 번쩍 떴다. 머릿속에 떠오른 파일 이름이 있었다.

그녀는 재빨리 컴퓨터로 돌아가 5차 프로젝트의 프론트엔드 폴더를 열었다. src 폴더 안, coverService.js라는 파일을 클릭하자 코드가 화면에 나타났다. 솔라의 눈이 특정 함수를 빠르게 훑었다.

// Frontend/src/coverService.js

export const generateCoverImage = async (prompt) => {
  const response = await fetch('https://api.openai.com/v1/images/generations', {
    // ... method, headers with API key ...
    body: JSON.stringify({
      model: 'gpt-image-2',
      prompt: prompt,
      n: 1,
      size: '1024x1536',
      quality: 'medium',
      response_format: 'b64_json',
    }),
  });
  // ...
};

“어?”

솔라의 입에서 짧은 탄성이 터져 나왔다. 코드에 적힌 API 주소는 자신들의 백엔드 서버 주소가 아니었다. api.openai.com. 명백한 OpenAI의 공식 API 엔드포인트였다.

“이럴 수가. 프론트엔드가… 직접 OpenAI랑 대화하고 있었네. 우리 백엔드를 거치지 않고.”

그녀는 코드의 나머지 부분을 마저 읽어 내려갔다. 이미지 모델은 gpt-image-2, 요청하는 이미지 크기는 1024x1536, 품질은 medium. 그리고 응답 형식으로 b64_json을 요청해서, 그걸 받아온 뒤 Data URL이라는 형식으로 변환하는 코드까지 전부 프론트엔드 서비스 파일 안에 있었다. 옆에 열어둔 tagService.js도 마찬가지로 OpenAI의 챗봇 모델 API를 직접 호출하고 있었다.

솔라는 허탈한 웃음을 지으며 의자에 등을 기댔다. 자신이 세웠던 가설이 완전히 빗나간 순간이었다. 백엔드는 AI 표지와 태그가 ‘생성’되는 과정에 전혀 관여하지 않았다. 그 책임은 온전히 프론트엔드가 지고 있었다.

“이제 알겠어. 5차 시스템에서 ‘AI 생성’의 책임은 프론트엔드에 있구나. coverService.jstagService.js 같은 서비스 모듈이 OpenAI API 호출부터 응답 데이터 가공까지 전부 처리하는 거였어. 4차 때랑은… 어, 잠깐. 4차 때도 그랬나?”

스스로의 질문에 솔라는 잠시 생각에 잠겼지만, 이내 고개를 저었다. “아니, 상관없지. 중요한 건 지금, 5차 시스템의 책임 경계니까. 생성은 프론트엔드, 저장은 백엔드. 이렇게 역할이 명확하게 나뉘어 있었던 거야.”

솔라는 화이트보드로 다가가 루나가 그려둔 ‘AI 이미지 생성’과 ‘AI 태그 생성’ 상자 주위에 점선으로 된 테두리를 그렸다. 그리고 그 테두리 바깥에 ‘Frontend’라고 크게 적었다. 책임의 영토가 분명해졌다.

한 가지 의문이 풀리자, 그 자리에 새로운 의문이 고개를 들었다.

“좋아, 그럼 이제 프론트엔드는 AI가 만들어준 표지 이미지 데이터랑 태그 목록을 손에 쥐고 있는 상태네. 하지만 이건 그냥 브라우저 메모리 안에 떠 있는 데이터일 뿐, 아직 우리 시스템의 진짜 ‘책’ 데이터가 된 건 아니잖아. 이 데이터를 어떻게 백엔드 사서한테 가져다주는 거지?“

3장: 5차 백엔드의 저장 책임: PATCH /books/{id}/cover

솔라는 자신이 화이트보드에 그린 점선 테두리를 가만히 응시했다. ‘Frontend’라는 글자 안에 ‘AI 이미지 생성’과 ‘AI 태그 생성’ 상자가 갇혀 있었다. 생성의 책임이 어디에 있는지 명확히 구분하고 나니, 시선은 자연스럽게 그 다음 단계로 향했다. 프론트엔드라는 섬에서 만들어진 결과물이 어떻게 저 단단한 백엔드 대륙으로 건너가는가.

루나는 말없이 다가와 솔라가 그린 그림 옆에 새로운 상자를 그렸다. ‘Backend’. 그리고 프론트엔드 상자에서 백엔드 상자로 향하는 화살표를 그었다. 하지만 화살표 위에는 도착지를 명시하는 대신, 굵은 물음표(?)를 그려 넣었다. 그 물음표가 마치 지금 솔라의 머릿속을 그대로 옮겨놓은 것 같았다.

솔라는 화살표 위의 물음표를 손가락으로 툭 치며 중얼거렸다. “가장 단순하게 생각하면… 프론트엔드가 자기가 가진 이미지 URL이랑 태그 목록을 그냥 백엔드에 던져주면 되지 않을까? ‘이 책 정보, 이걸로 업데이트해줘!’ 하면서. 그럼 백엔드가 알아서 데이터베이스에 저장하고.”

솔라의 말에는, 백엔드가 마치 모든 맥락을 이해하고 있는 마법 상자처럼 작동하리라는 막연한 기대가 섞여 있었다. 프론트엔드가 건네주는 것이 무엇이든 척척 받아서 제자리에 정리할 것이라는 믿음이었다.

루나는 솔라의 가설을 듣고는, 책상 위에 놓여 있던 빈 종이와 펜을 가져왔다. 그리고 종이 위에 두 개의 영역을 나누어 한쪽엔 ‘보내는 사람: Frontend’, 다른 쪽엔 ‘받는 사람: Backend’라고 적었다.

“우체국에서 소포를 보낸다고 생각해 보자. 보내는 사람이 내용물을 아무렇게나 상자에 던져 넣고 주소도 쓰지 않은 채 창구에 내밀면, 집배원 아저씨가 소포를 제대로 배달할 수 있을까?”

“안되지. 주소를 정확히 써야 하고, 받는 사람이 누구인지도 명확해야 해. 깨지기 쉬운 물건이면 ‘취급주의’ 스티커도 붙여야 하고.”

“바로 그거야. 프론트엔드가 백엔드에 데이터를 보낼 때도 마찬가지야. 받는 주소가 명확해야 하고, 소포 안의 내용물도 받는 쪽에서 이해할 수 있는 형태로 잘 포장되어 있어야 해. 그 ‘배송 규칙’은 어디에 적혀 있을까? 보내는 사람 마음대로 정하는 걸까, 아니면 받는 사람이 정해두는 걸까?”

루나의 비유에 솔라는 무릎을 탁 쳤다. “받는 사람이 정해야지! 받는 쪽에서 ‘이 주소로 보내주세요’, ‘내용물은 이런 형식으로 담아주세요’라고 미리 알려줘야, 보내는 쪽이 거기에 맞춰서 준비할 수 있잖아.”

“그럼 우리 시스템에서 그 ‘받는 규칙’을 어디서 찾아볼 수 있을까? 백엔드 API 문서를 뒤져본다고 상상해 봐.”

솔라는 눈을 감았다. 머릿속으로 백엔드 프로젝트 폴더를 열었다. 수많은 파일들 속에서, 외부의 요청을 가장 먼저 맞이하는 문지기, Controller라는 이름이 붙은 파일을 찾았다. BookController.java. 그 안에는 수많은 API 엔드포인트들이 정의되어 있을 터였다. 책의 표지와 태그를 ‘수정’하는 기능이니, 아마도 HTTP PATCH 메소드를 사용할 것이고, 주소에는 특정 책을 가리키는 id가 들어갈 것이다.

그 순간, 솔라의 머릿속에 코드 한 줄이 선명하게 떠올랐다.

// Backend/src/main/java/com/example/book/BookController.java

@PatchMapping("/books/{id}/cover")
public void updateBookCover(@PathVariable Long id, @RequestBody CoverUpdateRequest request) {
    bookService.updateCover(id, request.coverImageUrl(), request.tags());
}

그리고 그 요청을 받아들이기 위해 정의된 데이터 구조, CoverUpdateRequest의 모습도 함께 떠올랐다.

// Backend/src/main/java/com/example/book/CoverUpdateRequest.java

public record CoverUpdateRequest(String coverImageUrl, List<String> tags) {}

“아!” 솔라는 눈을 뜨며 외쳤다. “알겠다! 주소는 PATCH /books/{id}/cover였어. 그리고 소포의 내용물, 즉 데이터의 형식은 CoverUpdateRequest라는 이름의 약속으로 정해져 있었던 거야!”

그녀는 화이트보드로 달려가 물음표가 그려져 있던 화살표를 지웠다. 그리고 그 자리에 PATCH /books/{id}/cover라고 적었다. 화살표 끝, 백엔드 상자 바로 앞에는 작은 상자를 하나 더 그리고 그 안에 이렇게 썼다.

{ "coverImageUrl": "...", "tags": ["...", "...", "..."] }

“프론트엔드는 AI에게 받은 Data URLcoverImageUrl 필드에, 태그 목록을 tags 필드에 담아서, 이 정해진 JSON 구조체(소포)를 만들어야만 했던 거야. 그리고 그걸 정확히 /books/{id}/cover라는 주소로 보내야 백엔드가 비로소 ‘접수’를 해주는 거지. 그냥 막 던지는 게 아니라, 완벽하게 규격화된 요청이었어.”

이제 생성(Frontend)과 저장 요청(Backend API) 사이의 안개가 걷혔다. 각자의 역할과 둘 사이의 소통 방식이 명확하게 보였다. 프론트엔드는 생성 책임자이자, 백엔드가 정한 규칙에 맞춰 소포를 포장하는 발송인이었고, 백엔드는 소포의 주소와 형식을 검사하는 접수원이었다.

솔라는 자신이 새로 그린 다이어그램을 보며 만족스럽게 고개를 끄덕였다. 하지만 이내 새로운 의문이 고개를 들었다.

“좋아, 이제 프론트엔드가 보낸 소포가 백엔드 문 앞에 정확히 도착했어. BookController가 요청을 받았지. 그런데… 이 소포가 ‘진짜 책 데이터’가 되는 건 정확히 어느 순간이지? 컨트롤러가 요청을 받은 바로 그 순간? 아니면 BookService로 데이터가 넘어갔을 때? 아니면 데이터베이스에 저장이 완전히 끝났을 때?“

4장: AI 생성물이 Book 도메인 레코드가 되는 순간

솔라는 화이트보드에 새로 그려진 다이어그램을 보며 팔짱을 꼈다. 이전의 그림들과는 사뭇 다른, 백엔드의 내부를 해부한 듯한 그림이었다. ‘Controller’에서 ‘Service’로, 다시 ‘Repository’를 거쳐 최종 목적지인 ‘Database’로 향하는 화살표들이 일련의 과정을 보여주고 있었다. 프론트엔드에서 보낸 소포가 백엔드 문 안으로 들어온 뒤, 어떤 여정을 거치는지 보여주는 지도 같았다.

루나는 아무 말 없이 솔라에게 작은 자석 하나를 건넸다. 자석에는 ‘확정(Confirmed)’이라는 글자가 적혀 있었다.

“솔라 네가 던진 질문에 대한 답을 찾아보자.”

루나의 목소리는 차분했지만, 그 제안은 솔라에게 하나의 도전처럼 느껴졌다. 지난번 대화의 끝에서 솔라가 던졌던 질문—‘프론트엔드에서 보낸 데이터는 정확히 어느 순간에 진짜 ‘책 데이터’가 되는가?’—이 이제 눈앞의 다이어그램 위에서 답을 찾아야 할 과제로 바뀌어 있었다.

“이 ‘확정’ 자석을 네가 생각하는 바로 그 지점에 한번 붙여봐. 프론트엔드에서 온 손님이었던 데이터가, 비로소 이 집의 진짜 식구가 되는 순간 말이야.”

솔라는 자석을 손에 쥐고 다이어그램 앞으로 다가섰다. 망설임은 길지 않았다. 그녀는 가장 첫 번째 관문인 ‘Controller’ 상자 위에 자석을 ‘찰싹’ 붙였다.

“여기 아닐까? 어쨌든 BookController/books/{id}/cover 요청을 제대로 받았다는 건, 백엔드가 그 데이터를 공식적으로 접수했다는 뜻이잖아. 문을 열어줬으니, 이미 손님은 안으로 들어온 거지.”

프론트엔드에서 AI 생성이 완료되면 이미 Book 데이터일 것이라는 막연한 생각의 연장선이었다. 백엔드의 첫 관문만 통과하면 모든 것이 끝났다고 보는, 가장 단순하고 낙관적인 시나리오였다.

루나는 솔라가 자석을 붙인 위치를 가만히 보더니, 고개를 끄덕이는 대신 되물었다.

“그럼, 가상의 디버깅을 시작해 보자. 우리는 지금 BookControllerupdateBookCover 메서드 가장 첫 줄에 중단점(breakpoint)을 걸었어. 프론트엔드에서 보낸 요청이 방금 도착했고, 코드는 거기서 딱 멈춰있지. 바로 그 순간, 누군가 서버 전원 코드를 뽑아버렸다고 상상해 봐. 서버가 재부팅된 후에, 데이터베이스에 있는 그 책의 표지는 바뀌어 있을까?”

“아니, 그럴 리가.”

솔라는 즉답했다. 상상만으로도 결과는 명확했다.

“컨트롤러는 그냥 데이터를 받았을 뿐, 그걸로 아무것도 하지 않았잖아. 뒤에 있는 BookService를 호출하지도 않았으니까. 데이터는 컨트롤러의 메모리 안에서 그냥 사라졌을 거야.”

스스로 말하고 나서야 솔라는 자신의 실수를 깨달았다. 그녀는 ‘Controller’에 붙였던 자석을 떼어냈다. 접수만으로는 아무것도 보장되지 않았다. 진짜 일은 그 뒤에 있었다. 솔라는 자석을 들고 잠시 고민하다가, 다음 단계인 ‘Service’ 상자 위로 옮겨 붙였다.

“…그럼 여기네. 컨트롤러는 그냥 문지기고, 실제 로직은 BookService가 처리하니까. bookService.updateCover가 호출되는 순간, 진짜 작업이 시작되는 거잖아.”

“좋아, 한 단계 나아갔네.” 루나가 말했다. “그럼 중단점을 다시 옮겨보자. 이제 우리는 BookService.updateCover 메서드 안으로 들어왔어. 서비스가 데이터베이스에서 id에 해당하는 Book 엔티티 객체를 찾아왔고, 이제 막 그 객체의 coverImageUrl 필드 값을 프론트엔드에서 받은 새 URL로 바꾸려는 찰나야. 바로 이때, 다시 서버 전원 코드가 뽑힌다면?”

솔라의 미간이 살짝 찌푸려졌다. 아까보다 복잡한 상황이었다. 데이터베이스에서 책 정보를 가져오기까지 했고, 그 정보를 바꾸려는 순간이었다.

“음… 자바 객체, 그러니까 메모리 위에서는 값이 바뀌었겠지만… 그게 데이터베이스에 바로 반영되진 않지 않나? 트랜잭션이라는 게 있잖아.”

솔라는 BookService.updateCover 메서드의 코드를 머릿속으로 그려보았다. @Transactional 어노테이션이 붙어있던 것이 기억났다. 그 메서드가 시작될 때 트랜잭션이 열리고, 메서드가 성공적으로 끝나야 모든 변경 사항이 한꺼번에 데이터베이스에 반영(commit)된다. 만약 중간에 오류가 나면? 모든 것이 없었던 일(rollback)이 된다.

“알겠다!”

솔라는 ‘Service’ 상자에서 자석을 떼어냈다. 그리고는 상자 위가 아닌, ‘Service’에서 ‘Repository’를 거쳐 ‘Database’로 향하는 마지막 화살표 위, 거의 데이터베이스에 닿기 직전의 공간에 자석을 가져다 댔다.

“여기가 맞아. 어느 한 지점이 아니었어. updateCover라는 서비스 메서드가 아무런 오류 없이 끝나는 바로 그 순간, 그래서 트랜잭션이 성공적으로 커밋되는 그 시점에, 이 데이터는 비로소 ‘확정’되는 거야. 그 전까지는 아무리 서비스 로직 안에서 값이 바뀌고 계산이 이루어져도, 그건 전부 ‘예비’ 상태인 거지. 언제든 취소될 수 있는 임시 데이터.”

프론트엔드에서 생성된 데이터는 백엔드 컨트롤러에 의해 ‘접수’되고, 서비스 계층에서 ‘처리’되며, 이 모든 과정이 트랜잭션이라는 보호막 안에서 이루어진다. 그리고 그 트랜잭션이 성공적으로 ‘완료’될 때, 데이터는 비로소 영속성을 얻고 시스템의 일부가 된다. 생성된 AI 결과물이 ‘Book 도메인 레코드’가 되는 순간은 바로 여기였다.

솔라는 자신이 자석을 붙인 위치를 보며 중얼거렸다. “결국 데이터가 진짜 ‘내 것’이 되는 순간은, 시스템이 ‘이 변경은 이제 되돌릴 수 없습니다’라고 선언하는 바로 그 지점이었네.”

이제 생성부터 저장, 그리고 확정까지의 모든 흐름이 하나의 선으로 이어졌다. 프론트엔드라는 섬에서 태어난 데이터가 백엔드라는 대륙에 도착해, 복잡한 입국 심사(Controller, Service)를 거쳐 마침내 영주권(DB Commit)을 얻는 과정이 명확하게 그려졌다.

그녀는 만족스럽게 화이트보드를 바라보았다. 하지만 곧 새로운 의문이 고개를 들었다.

“좋아, 이제 데이터가 언제 확정되는지는 확실히 알겠어. 그런데… 이렇게까지 복잡하게 경계를 나누고, 여러 단계를 거쳐야 하는 이유가 뭐지? 프론트엔드가 생성해서 바로 데이터베이스에 저장하면 간단할 텐데. 왜 우리는 굳이 이런 복잡한 책임의 분리라는 걸 하고 있는 걸까?”

5장: 책임 경계 설정의 중요성: 4차와 5차의 교훈

솔라의 시선은 화이트보드에 머물러 있었다. 이전의 대화들로 빼곡했던 다이어그램들은 이제 부분적으로 지워지고 희미한 자국만 남아있었다. 생성, 저장, 확정에 이르는 복잡한 여정. 그 모든 흐름을 이해했지만, 마지막에 던졌던 질문이 머릿속을 맴돌았다. ‘왜 이렇게까지 복잡해야만 할까?’

그녀는 검은색 마커를 들고 화이트보드 앞으로 다가갔다. 그리고는 자신이 생각하는 가장 이상적이고 단순한 그림을 그리기 시작했다. 왼쪽에 ‘Frontend’ 상자, 오른쪽에 ‘Database’ 상자. 그리고 두 상자를 직선으로 이었다. 화살표 위에는 ‘직접 저장’이라고 썼다. 중간에 거추장스러운 관문도, 복잡한 절차도 없었다. 생성한 쪽이 알아서 창고에 넣으면 끝나는, 군더더기 없는 흐름. 그녀는 잠시 그림을 바라보며 중얼거렸다.

“이게 제일 간단하고 좋잖아. 프론트엔드가 AI한테 표지를 받아서, 바로 데이터베이스에 꽂아 넣으면… 빠르고… 어…?”

말을 내뱉는 순간, 솔라 스스로 자신의 논리에 균열을 느꼈다. ‘바로 꽂아 넣는다’는 건 대체 어떻게 가능하다는 말인가? 프론트엔드 코드에 데이터베이스 접속 정보를 넣어야 하나? 온갖 보안 문제와 규칙들이 머릿속을 스쳐 지나갔다. 그림은 단순했지만, 그 단순함을 현실로 만드는 방법은 전혀 단순하지 않았다. 오히려 더 위험하고 복잡해 보였다.

“그림이 단순하다고 해서, 시스템이 단순해지는 건 아니라는 걸 발견했구나.”

어느새 다가온 루나가 솔라의 옆에 서서 화이트보드를 바라보며 말했다. 그녀는 솔라가 그린 단순한 그림을 지우는 대신, 그 옆에 넓은 공간을 남겨두고 다른 색 마커 두 개를 건넸다.

“단순함에 대한 그 질문은 아주 중요해. 그 답을 찾기 위해, 우리가 지나온 길을 다시 한번 그려보는 건 어떨까? 4차 프로젝트의 AI 표지 데이터 흐름과, 지금 우리가 분석한 5차 프로젝트의 흐름을 나란히 말이야.”

솔라는 마커를 받아들었다. 이것이 지난 시간 동안의 대화를 종합하는 마지막 퍼즐 조각이 될 것 같았다. 그녀는 먼저 4차 프로젝트의 아키텍처를 그렸다.

Frontendjson-serverdb.json 파일

그림은 간단했다. 프론트엔드가 모든 것을 생성하고, json-server라는 임시 서버에 던지면, 서버는 그저 파일에 받아 적을 뿐이었다. 솔라는 json-server 상자 아래에 작은 글씨로 ‘규칙 없는 메모장’이라고 썼다.

이어서 그 옆에 5차 프로젝트의 흐름을 그렸다.

FrontendBackend APIServiceRepositoryDatabase

4차의 그림에 비해 훨씬 길고 복잡했다. 프론트엔드와 최종 데이터베이스 사이에 여러 개의 문지기들이 서 있는 모양새였다. 솔라는 Backend API 상자 아래에 ‘깐깐한 도서관 사서’라고 적었다.

두 개의 그림이 화이트보드 위에 나란히 놓이자, 둘의 차이는 극명하게 드러났다. 하나는 아무런 제약 없이 드나들 수 있는 허름한 창고였고, 다른 하나는 엄격한 절차를 거쳐야만 자료를 등록할 수 있는 국립 도서관이었다.

“좋아, 두 개의 시스템이 그려졌네.” 루나가 4차 다이어그램을 가리키며 말했다. “만약 우리가 여기에 새로운 규칙을 추가해야 한다면 어떨까? 예를 들어, ‘모든 책의 태그는 반드시 세 개여야 하고, 태그 글자 수는 10자를 넘을 수 없다’는 규칙을 말이야. 4차 시스템에서는 그 규칙을 어디에, 어떻게 적용해야 할까?”

“음…” 솔라는 ‘규칙 없는 메모장’을 노려봤다. “json-server는 규칙을 이해하지 못하니까… 결국 데이터를 보내는 프론트엔드가 알아서 규칙을 지켜서 보내야지. 프론트엔드 코드에 ‘태그는 3개인지?’, ‘글자 수가 10자가 넘지 않는지?’ 확인하는 로직을 넣어야 해.”

“만약 다른 프론트엔드, 예를 들어 모바일 앱이 추가되어서 json-server에 데이터를 저장해야 한다면?”

“그 모바일 앱에도 똑같은 검증 로직을 또 만들어 넣어야겠네. 깜빡하고 안 넣으면… 100자짜리 태그가 하나만 달린 엉망인 데이터가 파일에 그대로 저장되겠구나.”

솔라는 스스로의 대답에서 문제점을 발견했다. 데이터의 일관성을 보장할 수 있는 곳이 아무 데도 없었다. 책임이 흩어져 있었고, 저장소는 데이터의 상태에 아무런 관심이 없었다. 4차의 변화는 그저 구현 세부사항의 변경이 아니었다. 시스템의 정체성 자체가 바뀐 것이었다.

그녀의 시선이 5차 다이어그램으로 옮겨갔다.

“하지만 5차에서는… 저 규칙을 BookService에 넣으면 돼. updateCover 메서드 안에서, 프론트엔드가 보내준 태그 목록이 3개인지, 각 태그의 길이가 10자를 넘지 않는지 확인하는 거지. 규칙을 통과하지 못하면, 아예 데이터베이스까지 가지도 못하고 오류를 반환해버릴 테니까.”

“그렇지. 웹 프론트엔드든, 모바일 앱이든, 관리자용 스크립트든, 누가 요청하든 간에 모두 ‘깐깐한 도서관 사서’라는 단일한 창구를 거쳐야만 하는 거야. 데이터의 규칙과 일관성을 지키는 책임이 백엔드로 완전히 위임된 거지.”

“아…!”

솔라는 두 그림을 번갈아 보며 탄성을 터뜨렸다. 복잡해 보였던 5차의 구조가 왜 필요했는지, 그 이유가 온몸으로 이해됐다. 각 컴포넌트가 자신의 역할에만 집중하고, 데이터의 최종 상태를 책임지는 ‘수호자’를 두는 것. 그것이 바로 책임의 분리였다. 프론트엔드는 사용자에게 멋진 경험을 제공하는 것에 집중하고, 백엔드는 시스템의 핵심 자산인 도메인 데이터의 무결성을 지키는 데 집중한다.

솔라는 5차 다이어그램 위에 크게 ‘책임 분리 원칙’이라고 적었다. 더 이상 복잡해 보이기만 하던 그림이 아니었다. 각자의 역할을 충실히 수행하며 견고한 성을 지키는 병사들의 대열처럼 보였다.

그녀는 만족스러운 미소를 지으며 화이트보드 앞에 섰다. 4차와 5차의 혼란에서 시작해, 생성과 저장의 경계를 거쳐, 데이터 확정 시점을 지나, 마침내 이 모든 설계의 바탕에 깔린 원칙까지 도달한 여정이었다.

루나가 조용히 물었다. “만약 여기에 ‘AI가 책 내용을 한 문단으로 요약해주는 기능’을 추가한다면, 어떻게 설계할 거야?”

솔라는 한 치의 망설임도 없이 새로운 마커를 집어 들었다. 그리고 5차 다이어그램 옆 빈 공간에 빠르게 흐름을 그려나갔다.

“간단해. 프론트엔드에 summaryService.js를 만들어서 OpenAI를 호출하고 요약문을 받아오겠지. 그리고 그 요약문을 백엔드의 새 엔드포인트, 예를 들어 PATCH /books/{id}/summary로 보낼 거야. 그럼 백엔드의 BookService는 그 요약문이 너무 길거나 짧지 않은지 같은 규칙을 검증한 후에, Book 엔티티의 summary 필드에 저장하고 트랜잭션을 커밋하겠지. 생성 책임은 프론트엔드, 저장과 도메인 일관성 책임은 백엔드. 이제 이 경계는 절대 헷갈리지 않아.”

자신감 넘치는 솔라의 손끝에서 새로운 기능의 뼈대가 순식간에 완성되었다. 더 이상 ‘AI 기능’이라는 모호한 이름에 흔들리지 않았다. 그녀는 이제 어떤 기능이든 각 컴포넌트의 명확한 책임으로 분해하고 조립할 수 있는 자신만의 설계 원칙을 갖게 된 것이다.