여기까지 프로젝트를 하면서 처절하게 느낀 점
콘솔 볼 줄 알아야 한다
네트워크도 볼 줄 알아야한다
진짜 잘 알아야한다
모든 아카이브 페이지를 만들었으니 이제 검색창을 만들 차례이다
다른거 다 포기하고 그림 제목 범위로 한정
아카이브 메뉴 컴포넌트가 있어서 거기에서 구현하려고 한다
최초 기획도 그쪽에 검색창을 뒀었다
검색창 디자인은 https://uiverse.io/ 이쪽에서 가져왔다
참고로 색상 아카이브 쪽의 버튼도 이쪽에서 가져온 걸 수정했음
쓸만한 것들 미리 북마크 해둬서 정말 다행이다
디자인을 가지고 오다보니까 Search 라는 컴포넌트를 또 만들어서 그걸 아카이브 메뉴에다가 이식해야 했다
오히려 이게 더 좋은가? 검색창은 나중에 커뮤니티 페이지를 만들 때에도 쓸 수 있으니까

대충 구색은 맞췄다
이제 저기에 검색어를 입력하면 painting 테이블의 이름 컬럼을 뒤져서 맞는 검색어를 가지고 와야한다
딱 활용하기 좋아보이는 방법을 찾았다
검색버튼 없이 검색어를 치면 바로바로 목록이 나오는 실시간 검색 기능
집중적으로 봐야할 기능은 디바운싱, 모든 레퍼런스에 다 디바운싱이 활용되고 있다
연속적으로 발생하는 이벤트를 그룹화해서 마지막 이벤트만 처리한다고 한다
예를 들어
사용자가 모나리자 를 검색할 때
- 디바운싱 기능이 없다
모 모나 모나리 모나리자 > 총 네번의 요청을 보내야 한다
- 디바운싱 기능이 있다
사용자의 입력이 멈추고 일정시간이 지난 후 요청을 보내기 때문에 딱 한번의 요청만 보낸다
즉 서버로 향하는 쓸데없는 요청을 줄이면서 성능 최적화가 된다
그걸 사용하려면 lodash 라이브러리 설치가 필요하다
살펴보니까 무한 스크롤 기능도 여기서 사용할 수 있다고 한다
설치방법은 다른 라이브러리 설치방법(npm)과 똑같으니까 패스
자바 스크립트에서 사용하는 lodash 라이브러리의 주요 목적은 array, collection, date 같은 데이터의 필수 구조를 쉽게 다루는 것이다
배열 안 객체의 값들을 핸들링 할 때 유용하며
결과적으로 코드를 줄이고 빠른 작업에 도움이 된다
브라우저에서 지원되지 않는 성능을 구현하는 메서드들도 있고 무엇보다 npm 을 이용해 쉽게 설치 및 사용이 가능하다
시나리오는 이렇다
(리액트) 그림 클릭 -> 도슨트 페이지 보여줘 (/api/archive/docent)
(서버) (/api/archive/docent) 컨트롤러는 요청을 받고 서비스로 요청을 보내면서 응답을 기다린다
(1) 서비스는 클릭된 그림과 관련된 도슨트 데이터가 DB에 있으면 가져와서 리액트로 보낸다
(2) 서비스는 클릭된 그림과 관련된 도슨트 데이터가 DB에 없으면 gpt 에 프롬프트로 요청을 보내고 받아온 데이터를 insert 한 후 리액트로 보낸다
도슨트 페이지로 이동하면서 도슨트를 보여준다
프로젝트 주제가 정해지고나서 GPT의 도슨트 수준을 알아보려고 테스트를 해봤었다

이게 그 테스트 작품
그냥 그림만 주고 도슨트 시키기
이 작품에 대한 도슨트를 한국어로 해줘
이 작품은 물가에 모여 쉬고 있는 오리들을 그린 회화로, 평온한 일상의 순간을 따뜻하게 담아낸 풍경화입니다.
.
화면 중앙에는 흰 오리들이 자연스럽게 모여 있고, 일부는 물 위에, 일부는 물가에 몸을 맡긴 채 휴식을 취하고 있습니다. 오리들의 둥근 몸과 부드러운 깃털 표현은 두꺼운 붓질과 짧은 터치로 살아 있게 묘사되어 있어, 정적인 장면임에도 미묘한 움직임이 느껴집니다.
.
배경의 하늘과 물은 차분한 푸른색과 회색 계열로 처리되어 있으며, 구름이 드리운 빛의 변화가 수면에 반사되어 공간에 깊이를 더합니다. 왼쪽의 짙은 초록 수풀은 화면의 균형을 잡아 주며, 밝은 오리들과 대비를 이루어 시선을 자연스럽게 중앙으로 이끕니다.
.
전체적으로 이 작품은 인상주의적 화풍의 특징을 보입니다. 세밀한 묘사보다는 색과 빛, 순간의 분위기를 중시하며, 자연 속에서의 평화로운 공존과 여유로운 시간을 감상자에게 전합니다. 바쁜 일상 속에서 잠시 멈춰 서서 자연을 바라보는 듯한, 조용하고 안정적인 정서를 느끼게 하는 작품입니다.
그림+ 화가 + 작품 제목까지 주고 도슨트 시키기
Ducks on Lake Chiemsee in a Brewing Storm Alexander Koester (German, 1864-1932)
넌 미술관 도슨트야 이 작품에 대한 도슨트를 한국어로 해줘여러분, 지금 보고 계신 작품은 알렉산더 쾨스터(Alexander Koester)의
〈폭풍이 몰려오는 키엠제 호수의 오리들〉입니다.
.
쾨스터는 19세기 말부터 20세기 초까지 활동한 독일 화가로, 특히 오리를 주제로 한 풍경화로 매우 잘 알려져 있습니다. 당시 사람들 사이에서는 아예 “오리 화가”라고 불릴 정도였죠.
.
이 작품의 배경은 독일 바이에른 지방에 있는 키엠제(Chiemsee) 호수입니다. 그림 제목에서 알 수 있듯이, 지금은 평온해 보이지만 곧 폭풍이 몰려오기 직전의 순간을 담고 있습니다.
.
먼저 화면을 살펴보면,
전경에는 여러 마리의 오리가 물가에 모여 있습니다. 오리들은 서로 몸을 낮추거나 웅크린 자세를 하고 있는데, 이는 날씨 변화를 감지하고 긴장한 상태를 암시합니다. 특히 중앙의 흰 오리는 주변을 살피듯 고개를 들고 있어, 마치 무리의 중심 역할을 하는 듯 보입니다.
.
중경에는 짙은 녹색의 수풀이 자리 잡고 있습니다. 이 수풀은 바람에 흔들리는 듯 거칠게 표현되어 있으며, 다가오는 폭풍의 불안정한 기운을 전달합니다.
.
후경을 보면 하늘이 중요합니다.
푸른 하늘과 회색, 짙은 남색의 구름이 섞여 있는데, 붓질이 빠르고 두텁습니다. 이는 고요함보다는 변화와 움직임을 강조합니다. 호수 수면 역시 매끈하지 않고, 빛과 색이 부서지듯 반사되어 있어 날씨의 긴장감을 더합니다.
.
쾨스터의 특징 중 하나는 동물을 의인화하지 않으면서도 감정을 느끼게 만드는 표현력입니다. 오리들은 단순한 자연의 일부이지만, 관람자는 이 장면에서 폭풍 전의 정적, 불안, 그리고 잠시의 평온을 함께 느끼게 됩니다.
.
이 작품은 단순한 동물 그림이 아니라,
자연의 리듬과 순간의 분위기, 그리고 인간이 자연 앞에서 느끼는 감정을 은근히 투영한 풍경화라고 볼 수 있습니다.
.
잠시 작품 앞에 서서,
“곧 비가 내리기 직전의 공기”를 상상해 보시면 좋겠습니다.
확연한 퀄리티 차이가 난다
이걸 통해서 gpt 한테 요청을 보낼 땐 DB에서 이미지, 화가 이름, 작품 제목까지 동시에 다 보내줘야 한다는 조건을 확립했다
이제 문제는 응답의 길이와 문체였다
너무 관객에게 설명해준다는 느낌 보다는 일종의 산문같은 결과물을 내고 싶었다
일단 오픈api 는 토큰 사용량에 따라 요금을 부여하기 때문에 적당히 짧으면서 읽기 좋은 결과물을 받으려고 GPT에서 프롬프트 조건을 하나씩 추가하면서 결과물을 봤다
화가 이름이랑 작품 제목은 DB에 저장된대로 줬다
너는 서양화 전문 도슨트야.
[클로드 모네]의 작품 [양산을 쓴 여인 - 모네 부인과 그녀의 아들 (1875)] 에 대한 도슨트 글을 작성해줘.
인사말이나 부연 설명 없이 3문단 이내로 요약해서 바로 출력해줘.
각 문단은 공백 포함 300자 미만이야.Claude Monet의 1875년 작품 《양산을 쓴 여인–모네 부인과 그녀의 아들》은 인상주의가 지향한 순간의 빛과 공기의 떨림을 생생히 보여준다. 화면 아래에서 위를 올려다보는 구도는 인물을 하늘과 맞닿게 하며, 흩날리는 드레스와 구름, 바람에 기울어진 양산이 한순간 스쳐 가는 야외의 시간을 포착한다.
.
모델은 그의 아내 Camille Monet과 아들 장이다. 인물의 세부 묘사는 최소화되었지만, 밝은 흰색 드레스에 반사되는 푸른 음영과 초록 들판의 대비가 강렬한 색채 리듬을 만든다. 이는 형태보다 빛의 변화와 시각적 인상을 우선한 인상주의의 핵심 태도를 드러낸다.
.
이 작품은 단순한 가족 초상을 넘어, 자연 속에서 인간이 빛과 공기에 녹아드는 장면을 그린다. 짧고 빠른 붓질은 고정된 형상이 아니라 흐르는 시간을 암시하며, 관람자로 하여금 그 순간의 바람과 햇살을 체험하게 한다. 이는 모네가 평생 탐구한 ‘보이는 세계의 찰나성’을 상징적으로 보여준다.
네번의 테스트 결과 이 정도가 가장 마음에 드는 결과물이었다
https://flextudio.com/blog/openai-1
https://jypark1111.tistory.com/203
https://time-memorizer.tistory.com/256
이 가이드들을 참고하면서 따라갔다
참고로 발급 직후에 키를 복사해서 백업하는걸 추천
나중에 다시 복사하려고 했는데 (나는) 상세보기를 찾을수가 없었다
미리 백업 안해뒀으면 새로 발급을 받아야 했을 것 같다
그리고 이것도 구글 api처럼 실사용 하려면 요금 충전이 필요하다
화면 왼쪽 아래에 Go to billing 이라는 팝업을 누르면 결제수단을 등록하고 크레딧을 충전할 수 있는데
거기서 5달러라도 충전을 해놔야 api 를 사용할 수 있다
그리고 application.yml 에 api 키를 넣어야 한다

넣는 위치는 맨 밑쪽
gpt:
api:
key: 발급받은 키 넣기
스페이스바를 두번씩 눌러줘야한다
아니면 그냥 이걸 복붙해도 되고
일단 서버쪽에 도슨트 관련 컨트롤러-매퍼 라인을 쭉 만들어줘야한다
기존에 작품 가져올 때 사용하던 main 은 작품 데이터를 가지고 오는 기능에만 집중하기로 하고
도슨트 관련은 따로 루트를 만들어주기로 했다
<Route path="/api/archive/docent" element={<Docent />} />
리액트의 Route 로 이 주소를 사용하기로 결정했기 때문에
@RequestMapping("/api/archive/docent")
컨트롤러에서도 이 주소로 요청을 받는걸로 기초 시작
@Data // Getter, Setter 등을 자동으로 만듦
@AllArgsConstructor // 모든 필드 생성자
@NoArgsConstructor // 기본 생성자
@Builder // 빌더 패턴 적용
public class Docent {
private int id; // PK
private int paintingId; // 그림 고유 번호
private String body; // 도슨트 내용
private LocalDateTime regDate; // 등록일
private LocalDateTime updateDate; // 수정일
}
일단 vo 에서는 작품 아이디 + 도슨트 내용만 담고있다
@RestController // 컨트롤러 선언
@RequestMapping("/api/archive/docent") // 요청 경로 지정
@CrossOrigin(origins = "http://localhost:3000") // 리액트 포트 허용
public class DocentController {
@Autowired // 서비스 주입
private DocentService docentService;
// Painting 객체를 받아서 Docent 객체를 바로 반환함
@PostMapping
public Docent getDocent(@RequestBody Painting painting) {
// 서비스에서 결과(Docent)를 가져와서 그대로 리턴함
return docentService.getOrCreateDocent(painting);
}
}
데이터를 넘겨주는 입구+통로일 뿐이라 별다른 건 없다
데이터를 가져오거나 생성해야 하기 때문에 이름은 저렇게 했고
이쪽은 이제 로직이 들어가야 한다
@Service // 서비스 선언
public class DocentService {
@Autowired // 리포지토리 주입
private DocentRepository docentRepository;
@Autowired // GPT 서비스 주입
private GptService gptService;
public Docent getOrCreateDocent(Painting painting) {
// DB에 이미 저장된 도슨트가 있는지 조회한다
Docent docent = docentRepository.findByPaintingId(painting.getId());
// 만약 도슨트 데이터가 없다면?
if (docent == null) {
// 여기서 프롬프트를 정의
String prompt = String.format(
"너는 서양화 도슨트야. 화가 %s의 '%s' 작품에 대한 도슨트 글을 작성해줘. " +
"**인사말이나 부연 설명 없이 3문단 이내로 요약**해서 바로 출력해줘. " +
"**각 문단**은 공백 포함 **300자 미만**이야.",
painting.getPainterNameKr(),
painting.getPaintingNameKr()
);
// 완성된 프롬프트를 GptService에 던짐
String gptContent = gptService.askGpt(prompt);
// 객체 생성 및 저장
docent = Docent.builder()
.paintingId(painting.getId())
.body(gptContent)
.build();
docentRepository.save(docent);
}
return docent;
}
}
yml 파일에 저장해놓은 api 키는 이쪽에서 사용이 된다
https://developers.openai.com/api/docs/models
이쪽에서 사용 가능한 모델들을 볼 수 있고
https://developers.openai.com/api/reference/resources/chat
이쪽에서 어떻게 사용하는지 볼 수 있다
https://adjh54.tistory.com/234
RestTemplate = http 통신 도구를 상세하게 설명한 티스토리
@Service // 서비스 선언
public class GptService {
// API 키를 가져와서 apiKey 변수의 값으로 사용
@Value("${gpt.api.key}")
private String apiKey;
// 서버가 요청을 보낼 주소, 바뀌면 안되니까 상수로 선언
private final String URL = "https://api.openai.com/v1/chat/completions";
// GPT에게 질문(prompt)을 전달하고 답변을 텍스트로 받는 메서드
public String askGpt(String prompt) {
// 다른 서버(OpenAI)에 요청을 보내기 위한 통신 도구
RestTemplate restTemplate = new RestTemplate();
// 요청에 붙일 HTTP 헤더를 만든다
HttpHeaders headers = new HttpHeaders();
// 이 요청은 JSON이라고 명시
headers.setContentType(MediaType.APPLICATION_JSON);
// API 키까지 헤더에 담는다
headers.setBearerAuth(apiKey);
// GPT에게 보낼 요청(body)
// json 이니까 맵 형태로 만들었다
Map<String, Object> requestBody = new HashMap<>();
// gpt-4o 모델을 사용한다
requestBody.put("model", "gpt-4o");
// "누가(role)" 말하고, "내용(content)"이 뭔지 한 세트로 묶은 Map
// { "role": "user", "content": prompt } 모양이 된다
requestBody.put("messages", List.of(
Map.of(
"role", "user",
"content", prompt
)
));
// header 와 body 를 묶어서 entity 라는 객체로 만든다
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
// 완성된 택배를 OpenAI 주소(URL)로 보내고
// GPT가 대답한 결과를 Map 형태로 받았다
Map<String, Object> response = restTemplate.postForObject(URL, entity, Map.class);
// GPT가 준 결과 안에서 'choices'라는 이름의 대답 목록만 가져온다
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
// 대답 목록 중에 제일 첫 번째(0번째)를 고르고
// 그 안에서 'message' 만 꺼낸다
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
// 마지막으로 껍데기 다 버리고 진짜 사람이 읽을 수 있는 텍스트('content')만 최종적으로 남긴다
return (String) message.get("content");
} catch (Exception e) {
// 만약 인터넷이 끊기거나 키가 틀려서 에러가 나면, 서버가 뻗지 않게 에러 내용을 알려준다
return "오류 발생: " + e.getMessage();
}
}
이 부분은 제미나이 도움을 받아서 겨우 만들었다...
@Mapper // MyBatis 매퍼 인터페이스임을 선언
public interface DocentRepository {
// 그림 ID로 도슨트 데이터를 찾음
Docent findByPaintingId(@Param("paintingId") int paintingId);
// 새로운 도슨트 정보를 저장함
void save(Docent docent);
}
필요한 쿼리는 두개다
select 랑 insert
save 쪽은 딱히 결과값을 반환할 필요가 없기 때문에 void 를 사용
<mapper namespace="com.example.demo.repository.DocentRepository">
<select id="findByPaintingId" resultType="Docent">
SELECT id, paintingId, body, regDate, updateDate
FROM docent
WHERE paintingId = #{paintingId}
</select>
<insert id="save">
INSERT INTO docent (paintingId, body, regDate, updateDate)
VALUES (#{paintingId}, #{body}, NOW(), NOW())
</insert>
</mapper>
mapper 에서 쿼리를 짜주면 된다
https://phsun102.tistory.com/192
useNavigate 라는 도구가 필요하다
url을 변경하여 페이지를 이동시키는데 사용하는 도구이다
import { useNavigate } from 'react-router-dom';
라우터에서 import 해주고
const navigate = useNavigate();
import 를 했으면 변수 선언을 해줘야한다
<div className="flex flex-col items-center xl:items-end shrink-0">
{/* onClick 이벤트를 추가하여 전체 데이터(todayPainting)를 state에 담아 도슨트 페이지로 넘긴다
마우스를 올렸을 때 클릭 가능함을 알리기 위해 cursor-pointer 추가
*/}
<div
onClick={() => {
navigate(`/api/archive/docent?paintingId=${todayPainting.id}`, {
state: { painting: todayPainting }
});
}}
className="cursor-pointer relative z-10 w-[95vw] sm:w-[500px] xl:w-[600px] card p-4 shadow-2xl bg-gradient-to-br from-gray-200 via-white to-gray-50 border border-gray-100 rounded-sm"
>
{/* 이미지가 클릭을 방해하지 않도록 pointer-events-none 추가 */}
<div className="overflow-hidden shadow-inner border border-gray-200 pointer-events-none">
<img
src={todayPainting.imgUrl}
alt={todayPainting.paintingNameKr}
className="w-full h-auto block transform hover:scale-105 transition-transform duration-500 pointer-events-none"
/>
</div>
</div>
이렇게 메인화면에서 보여주는 이미지를 클릭하면 도슨트 페이지로 이동할 수 있게 됐다
도슨트 페이지는 서버에서 만들어준 도슨트 내용을 가지고 와야한다
import React, { useEffect, useState } from "react";
// 이전 페이지에서 보낸 데이터를 받기 위해 useLocation을 추가로 가져옴
import { useNavigate, useLocation } from "react-router-dom";
// 서버와 통신하기 위해 axios 가져옴
import axios from "axios";
export const Docent = () => {
const navigate = useNavigate();
// 주소창에 실려온 데이터를 받기 위한 도구 선언
const location = useLocation();
// Main이나 Total 페이지에서 넘어온 그림 데이터가 있는지 확인하고 pData에 저장
const pData = location.state?.painting;
// 도슨트 본문을 저장할 공간 (초기값은 빈 문자열)
const [docentBody, setDocentBody] = useState("");
// GPT가 글을 쓰는 동안 로딩 중임을 표시할 상태
const [loading, setLoading] = useState(true);
// 화면이 켜지자마자 서버에 데이터를 보내서 도슨트 글을 받아옴
useEffect(() => {
if (pData) {
setLoading(true);
// 서버로 pData 전체를 보내서 도슨트 생성을 요청함
axios.post("http://localhost:8081/api/archive/docent", pData)
.then(res => {
// 서버가 만들어준 글을 docentBody에 저장함
setDocentBody(res.data.body);
setLoading(false);
})
.catch(err => {
console.error("서버 에러:", err);
setLoading(false);
});
}
}, [pData]);
// 예외 처리: 만약 주소창을 치고 몰래 들어오면 튕겨냄
if (!pData) {
return <div className="text-center pt-20">작품 정보가 없습니다. 목록에서 들어와주세요!</div>;
}
// 화면에 보여지는 영역
return (
<div className="bg-[#E5E7EB] w-full min-h-screen flex justify-center items-start pt-20 pb-20 font-notos text-black">
<div className="w-[1000px] bg-white shadow-2xl flex flex-col px-16 py-16 relative rounded-[4px]">
{/* 상단: 작품 정보 영역 */}
<div className="flex flex-col mb-10">
{/* pData 안의 한글 이름 출력 */}
<h1 className="text-[42px] font-song-bold mb-4">{pData.paintingNameKr}</h1>
<div className="space-y-1 text-[15px] text-gray-700">
{/* 각 항목에 맞는 실제 데이터 매핑 */}
<p className="font-medium text-black">{pData.paintingNameEn}</p>
<p>{pData.painterNameEn}</p>
<p className="pt-2">{pData.painterNameKr}</p>
<p>{pData.moveNameKr}</p>
</div>
</div>
{/* 구분선 */}
<div className="w-full border-t border-gray-300 mb-12"></div>
{/* 중앙: 이미지 및 슬라이드 제어 영역 */}
<div className="relative w-full h-[500px] bg-[#F3F3F3] flex items-center justify-center mb-12 group">
{/* 작품의 실제 이미지 주소를 연결 */}
<img
src={pData.imgUrl}
alt={pData.paintingNameKr}
className="max-w-full max-h-full object-contain shadow-lg"
/>
{/* 좌우 화살표 */}
<button className="absolute left-4 w-12 h-12 flex items-center justify-center bg-black/10 hover:bg-black/30 rounded-full transition-all text-white text-2xl">
{"<"}
</button>
<button className="absolute right-4 w-12 h-12 flex items-center justify-center bg-black/10 hover:bg-black/30 rounded-full transition-all text-white text-2xl">
{">"}
</button>
</div>
{/* 하단: 도슨트 내용 영역 */}
<div className="mb-16">
<p className="text-[15px] leading-[1.8] text-gray-800 text-justify whitespace-pre-wrap">
{/* 로딩 중이면 안내 문구를 띄우고, 끝나면 서버에서 받은 도슨트 본문을 출력 */}
{loading ? "작품을 분석 중입니다... 🎨" : docentBody}
</p>
</div>
{/* 하단 버튼 영역 */}
<div className="flex justify-between items-center border-t border-gray-100 pt-8 mt-auto">
<button
onClick={() => navigate(-1)}
className="bg-[#D1D5DB] hover:bg-gray-400 text-black px-8 py-2 rounded-[2px] text-sm font-bold transition-colors"
>
뒤로가기
</button>
</div>
</div>
</div>
);
};
export default Docent;
일단 여기까지만 하고 산출물 완성하고 나서 뭘 더 해야할 것 같다