정말 오랜만에 쓰는데... 그동안 졸작했다가 며칠전에는 정처기 필기 봤다가... 이제 남은 시간동안 뭐할까 고민하다가 개인 프로젝트 더 하기로 함 ㅎㅎ
아무튼 바로 시작!!
01. 구상
1. 집에 무슨 약이 있고 소비기한 얼마 남았는지 몰라서 맨날 찾아봐야 됨 -> 약 관리 기능 (CRUD 구성)
2. 증상이 나타날 때 집에 있는 약 중에서 무얼 어떻게 복용해야 하는지, 그 외에 필요한 약이나 영양제, 병원 등이 무엇이 있는지, 증상의 원인이나 치료법 등이 궁금함 -> 현재 저장된 약 DB + AI LLM 모델 연결해서 자동 진단 추천 기능
3. 현재 위치 근처에 어떤 병원들이 있는지 -> 카카오 지도 API 연결해서 마커, 이름, 전화번호 표시
위 3가지 기능들을 구현할 것이다. 따라서 페이지도 아래처럼 나뉜다.
1. 메인 페이지: 약 전체 조회 (소비기한 임박순으로 정렬, 웹과 모바일 각각 반응형으로 맞게 그리드로 조절)
1-2. 상세 조회 페이지: 약 상세 정보 조회 (사진, 이름, 소비기한, 남은 수량, 복용법)
1-3. 새 약 페이지: 이름, 기한, 개수, 복용법, 이미지 url 작성 후 등록
+삭제는 경고 알림만 띄우기, 수정과 삭제는 비밀번호 검증 후 가능(관리자 권한), 그 외는 여러 사용자가 다 이용 가능
2. 진단 페이지: 텍스트폼에 사용자가 증상을 입력 -> 그 아래 AI 진단 결과가 출력
3. 병원 페이지: 카카오 지도가 화면에 띄워지고 현재 위치 기반 주변 병원 정보 마커로 표시
02. 기술 스택
이건 늘 그랬듯이 똑같이 사용
백엔드: Spring Boot, MariaDB
프론트엔드: html, css, js (한 프로젝트 내에서 간단하게만 구성)
배포: cloudType 프리티어
전체 구성


크게 약관리 + 진단 기능으로 나누었다. 지도는 백엔드가 필요 없으므로 프론트엔드에서만 작성하였다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.4'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'DoctorHome'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
// JSON 파싱용 Jackson
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'io.github.cdimascio:java-dotenv:5.2.2'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
의존성으로 필요한 것들 넣었는데 여기서 json 파싱은 LLM 모델 답변 파싱할 때 사용하려고 한 거
그런데 그냥 진단 전체 결과를 가져오는 게 더 나아서 이거 안 쓴 듯
그리고 그 아래 dotenv는 API 키들을 환경변수 .env 파일에 넣고 이건 git에서 숨김처리하려고 넣은 거였는데 이것도 비공개 레포지토리로 하면서 키를 그냥 입력했기 때문에 필요없어졌음
03. 설정
여기에서 고민이 많았는데, 처음에는 공개 레포지토리로 해서 주요 키나 비밀번호 등을 다 변수 처리했었다. 그런데 이번 프로젝트는 기능 구현에 초점을 두어서 비공개 레포지토리로 바꾼 후 키들을 아래에 다 써주었다.
-주소
1. 로컬 서버: localhost:3306
2. 배포 서버: 클라우드타입에서 mariaDB 배포 주소 그대로 적으면 됨
-이름과 비밀번호도 로컬에선 로컬로 설정한대로, 배포에서는 배포에서 적용한 것대로(주로 root)
application.yml
gemini:
api:
key: (키)
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
datasource:
url: jdbc:mariadb://(주소)/DoctorHome
username: (이름)
password: (비밀번호)
driver-class-name: org.mariadb.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MariaDBDialect
format_sql: true
jackson:
serialization:
indent_output: true
ddl-update는 개발 단계에서는 create로 하고 CRUD 기능 구현 완료 후에는 update로 바꾸었다.
AppConfig
서비스에서 모델과 통신할 때 restTemplate를 이용했는데, 이를 위해서는 Appconfig 파일을 추가해서 빈 등록을 해주어야 한다.
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
04. 도메인 설계
이번에는 크게 연관관계 설정이 필요없기 때문에 한 테이블로만 구성하였다.

Medicine.java
@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Medicine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private LocalDate expiryDate;
private int quantity;
@Column(length = 500)
private String medicine_usage; //복용법
private String imageUrl;
}
05. 코드 작성 - 백엔드
그 이후부터는 순서대로 코드를 작성하면 된다.
우선 약 관리 기능인 CRUD부터 만들면 다음과 같다.
controller
@RestController
@RequestMapping("/api/medicines")
@RequiredArgsConstructor
public class MedicineController {
private final MedicineService medicineService;
/* 전체 조회 */
@GetMapping
public List<Medicine> getAll(
@RequestParam(required = false) String search, //약 검색 기능
@RequestParam(required = false) String sort //소비기한 순 정렬
) {
return medicineService.findAllWithSearchAndSort(search, sort);
}
/* ID 조회 */
@GetMapping("/{id}")
public Medicine getById(@PathVariable Long id) {
return medicineService.findById(id);
}
/* 등록 */
@PostMapping
public ResponseEntity<Medicine> create(@RequestBody MedicineRequest request) {
Medicine saved = medicineService.save(request);
return ResponseEntity.ok(saved);
}
/* 수정 */
@PutMapping("/{id}")
public ResponseEntity<Medicine> update(
@PathVariable Long id,
@RequestBody MedicineRequest request
) {
Medicine updated = medicineService.update(id, request);
return ResponseEntity.ok(updated);
}
/* 삭제 */
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
medicineService.delete(id);
return ResponseEntity.noContent().build();
}
}
이것도 처음에는 이미지 업데이트 때문에 멀티파츠로 작성했다가 너무 복잡해져서 링크만 업데이트하는 것으로 하고 위처럼 변수로만 받도록 바꾸었다.
service
@Service
@RequiredArgsConstructor
public class MedicineService {
private final MedicineRepository medicineRepository;
/* 전체 조회 */
public List<Medicine> findAll() {
return medicineRepository.findAll();
}
public List<Medicine> findAllWithSearchAndSort(String search, String sort) {
List<Medicine> medicines;
//검색
if (search == null || search.isEmpty()) {
medicines = medicineRepository.findAll();
} else {
medicines = medicineRepository.findByNameContainingIgnoreCase(search);
}
//소비기한 임박순 정렬
if ("expiry".equals(sort)) {
medicines.sort((a, b) -> {
if (a.getExpiryDate() == null) return 1;
if (b.getExpiryDate() == null) return -1;
return a.getExpiryDate().compareTo(b.getExpiryDate());
});
}
return medicines;
}
/* ID 조회 */
public Medicine findById(Long id) {
return medicineRepository.findById(id)
.orElseThrow(() -> new RuntimeException("약을 찾을 수 없습니다. id=" + id));
}
/* 등록 */
public Medicine save(MedicineRequest request) {
Medicine medicine = Medicine.builder()
.name(request.getName())
.expiryDate(request.getExpiryDate() != null ? request.getExpiryDate() : LocalDate.now())
.quantity(request.getQuantity())
.medicine_usage(request.getMedicine_usage())
.imageUrl(request.getImageUrl())
.build();
return medicineRepository.save(medicine);
}
/* 수정 */
public Medicine update(Long id, MedicineRequest request) {
Medicine medicine = findById(id);
medicine.setName(request.getName());
medicine.setExpiryDate(request.getExpiryDate() != null ? request.getExpiryDate() : LocalDate.now());
medicine.setQuantity(request.getQuantity());
medicine.setMedicine_usage(request.getMedicine_usage());
medicine.setImageUrl(request.getImageUrl());
return medicineRepository.save(medicine);
}
/* 삭제 */
public void delete(Long id) {
medicineRepository.deleteById(id);
}
}
이렇게 하면 약 관리 CRUD 기능은 구현 완료된다. 게시판 CRUD 작성을 공부한게 도움이 되었던 것 같다.
그 다음은 핵심 기능인 AI 진단 기능에 관한 백엔드 코드이다.
controller
@RestController
@RequestMapping("/api/diagnosis")
@RequiredArgsConstructor
public class DiagnosisController {
private final DiagnosisService diagnosisService;
private final MedicineService medicineService; //현재 약 정보 넘기기
@PostMapping
public ResponseEntity<String> diagnose(@RequestBody DiagnosisRequest request) {
//현재 등록된 약 이름만 추출
List<String> registeredMeds = medicineService.findAll()
.stream()
.map(Medicine::getName)
.collect(Collectors.toList());
//AI 진단 요청 (증상 + 등록된 약)
String result = diagnosisService.diagnoseWithMedicines(request.getSymptoms(), registeredMeds);
return ResponseEntity.ok(result);
}
}
사용자가 등록한 약 정보들을 ai한테 넘겨야 무얼 먹어야 하는지 추천해줄 수 있으므로, 아까 만들었던 서비스에서 이름만 추출하여 진단 서비스로 넘긴다.
service
우선 아까 등록했던 restTemplate을 가져오고, Value로 yml에 등록했던 키를 가져온다.
private final RestTemplate restTemplate;
@Value("${gemini.api.key}")
private String apiKey;
private final String modelName = "gemini-2.5-flash";
여기서 사용한 모델은 제미나이 무료 버전이다. 아래 사이트를 참고하면 쉽게 키를 발급받을 수 있다.
Gemini API Key 발급 받기
Gemini API란 무엇일까요?Gemini API는 Google에서 개발한 최첨단 AI 모델 Gemini를 활용하여 다양한 AI 기능을 구현할 수 있도록 제공하는 API입니다. 강력한 언어 이해 능력, 복잡한 문제 해결 능력, 창의
doldol.kr
그리고 만약 비공개 레포지토리가 아닌 경우, 키를 환경변수로 관리하고 싶다면 아래처럼 .env 파일을 만든 후에 프로젝트 폴더에 넣어주면 된다.


위 방법 사용시 처음에 말했듯이 의존성에 dotenv를 추가해야 하고, Application.java 실행 코드 위에 아래 코드를 추가하면 된다.
@SpringBootApplication
public class DoctorHomeApplication {
public static void main(String[] args) {
//추가 부분
Dotenv dotenv = Dotenv.load();
System.setProperty("GEMINI_API_KEY", dotenv.get("GEMINI_API_KEY"));
SpringApplication.run(DoctorHomeApplication.class, args);
}
}
다시 서비스 코드로 돌아와서, 모델과 통신하는 부분을 작성해주면 된다. 그중에서 사실 제일 중요한 건 프롬프트 작성 부분이다. 정확하고 자세하게 작성할수록 사용자가 대충 입력해도 결과가 잘 나온다. 여기서는 아래처럼 작성하였다.
//AI에게 보낼 프롬프트
String prompt = String.format("""
당신은 의사 또는 약사입니다.
사용자의 증상을 보내드릴테니, 그 증상에 맞는 일반적인 진단, 약, 영양제, 치료법 또는 병원 등을 추천해주세요.
사용자가 이미 가지고 있는 약 목록: %s
증상: %s
1. 가지고 있는 약 중에서 증상에 맞는 약이 있으면 추천해주세요.
2. 없으면 다른 약을 추천하고, 이유를 설명해주세요.
""", medsText, symptom);
06. 코드 작성 - 프론트엔드
난 백엔드쪽이기 때문에 프론트엔드는 간단하게만(ㅠ.ㅠ) 구성하였다...
그래도 원하는 쪽으로 최대한 푸른 계열 + 깔끔한 디자인 + 정사각형 사진 그리드로 구현함
일단 메인 페이지
index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>약 목록</title>
<link rel="stylesheet" href="/css/style.css">
<script defer src="/js/main.js"></script>
</head>
<body>
<header>
<h1>💊 약 관리</h1>
<div class="header-controls">
<input type="text" id="search" placeholder="약 이름 검색...">
<button onclick="location.href='/edit.html'">+ 새 약 등록</button>
<button onclick="location.href='/diagnosis.html'">진단하기</button>
<button onclick="location.href='/hospital.html'">병원찾기</button>
</div>
</div>
</header>
<main>
<div id="medicine-list" class="grid"></div>
</main>
</body>
</html>
다른 html도 거의 비슷한 형식인데 여기서 중요한 점은 요거!!
<meta name="viewport" content="width=device-width, initial-scale=1.0">
이거 모르고 안 썼다가... 기껏 반응형으로 css 짰는데 모바일에서도 컴퓨터처럼 글씨가 작게 나오는 바람에 다시 수정함
뷰포트를 써줘야 모바일에서도 렌더링이 지원된다고 한다. 실제로 이거 추가해줬더니 이제 모바일에서도 반응형 돼서 크기 조절됨!
그리고 또 주요 코드는 병원 지도 넣기
hostpital.html
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=(발급 키)&libraries=services"></script>
<script defer src="/js/hospital.js"></script>
html에서 js 호출 전에 카카오 지도 api 키를 넣어주었다. 이 방법은 예전에 만들었던 프로젝트 참고!
[토이프로젝트 개발일지 03] 산책 경로 저장 기능 추가 (카카오 지도 api)
더보기사실 기능 추가할 때마다 깃허브에 readme로 쓰고 있었다가... 이제서야 한번에 정리 겸 글 써봄 산책(이동) 경로 저장 기능 개발01. 구상25.01.13 01.12에 서버 배포를 완료한 뒤, 다음날에
rim08.tistory.com
그런데 이제 정책이 바뀐 건지 그냥 마음대로 앱 키를 활성화 시킬 수가 없다... 무슨 비즈앱 전환에 자격 신청하라는데 일단 난 테스트 개발용이라서 테스트앱 생성으로 했다. 그러면 따로 검사 안해도 바로 활성화시킬 수 있다.
그래서 지도 띄우는 js는 다음과 같다.
hospital.js
document.addEventListener("DOMContentLoaded", () => {
const mapContainer = document.getElementById("map");
const hospitalList = document.getElementById("hospital-list");
//지도 기본 옵션 (서울 시청 기준)
const mapOption = {
center: new kakao.maps.LatLng(37.5665, 126.9780),
level: 3
};
const map = new kakao.maps.Map(mapContainer, mapOption);
const ps = new kakao.maps.services.Places();
const infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });
//현재 위치 받아오기
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
const locPosition = new kakao.maps.LatLng(lat, lon);
map.setCenter(locPosition);
//현재 위치 마커 표시
const marker = new kakao.maps.Marker({ position: locPosition });
marker.setMap(map);
//현재 위치를 중심으로 병원 검색
ps.keywordSearch("병원", (data, status) => {
if (status === kakao.maps.services.Status.OK) {
displayHospitals(data);
}
}, { location: locPosition, radius: 5000 });
});
} else {
alert("위치 정보를 가져올 수 없습니다.");
}
//병원 목록 표시 함수
function displayHospitals(places) {
hospitalList.innerHTML = "";
places.forEach((place) => {
const item = document.createElement("div");
item.className = "hospital-item";
item.textContent = `${place.place_name} (${place.phone || "전화번호 없음"})`;
hospitalList.appendChild(item);
const marker = new kakao.maps.Marker({
map: map,
position: new kakao.maps.LatLng(place.y, place.x)
});
kakao.maps.event.addListener(marker, "click", () => {
infowindow.setContent(`<div style="padding:5px;">${place.place_name}</div>`);
infowindow.open(map, marker);
});
item.addEventListener("click", () => {
const moveLatLon = new kakao.maps.LatLng(place.y, place.x);
map.panTo(moveLatLon);
infowindow.setContent(`<div style="padding:5px;">${place.place_name}</div>`);
infowindow.open(map, marker);
});
});
}
});
사용자의 현재위치를 기반으로 주변 병원 정보를 검색하여 마커로 표시하고, 그 아래에 목록으로도 병원 이름+전화번호 형태로 출력한다.
또 js에서 이번에 추가한 기능은 '관리자 권한 검증'기능이다.
원래는 로그인 기능을 만드는게 더 보안성이 높겠지만, 이 사이트는 로그인 없이도 가능한 기능들이 대다수이므로 굳이 구현하지 않았다. 따라서 주요 버튼인 수정과 삭제 버튼에만 비밀번호를 입력하게끔 팝업을 띄우는 것으로 구현하였다.
detail.js
//비밀번호 검증 함수
function checkPassword() {
const input = prompt("비밀번호를 입력하세요:");
const correctPassword = "(비밀번호->나중에 서버 검증으로 보완할수도 있음)";
return input === correctPassword;
}
//삭제 버튼
document.getElementById("delete-btn").addEventListener("click", () => {
if (!checkPassword()) {
alert("비밀번호가 올바르지 않습니다.");
return;
}
if (confirm("정말 삭제하시겠습니까?")) {
fetch(`/api/medicines/${id}`, { method: "DELETE" })
.then(res => {
if (!res.ok) throw new Error("삭제 실패");
location.href = "/index.html";
})
.catch(err => alert("삭제 중 오류가 발생했습니다."));
}
});
//수정 버튼
document.getElementById("edit-btn").addEventListener("click", () => {
if (!checkPassword()) {
alert("비밀번호가 올바르지 않습니다.");
return;
}
location.href = `/edit.html?id=${id}`;
});
07. 배포
코드를 다 작성하고 로컬 서버 실행 후 정상작동까지 확인했다면, 마지막으로 배포만 남았다.
우선 기본적인 배포 방법은 마찬가지로 예전에 했던 프로젝트의 배포 내용을 참고하였다. (다 까먹었는데 자세히 써놔서 진짜 다행)
[토이프로젝트 개발일지 02] Spring Boot, MariaDB 배포(CloudType)
어제 글을 쓰다보니까 진짜로 서버 배포 해봐야겠다 싶어서 온갖 사이트를 찾아다닌 결과...cloudType에서 무료로 가능하다길래 몇번의 실패 끝에 성공함!!그런데 무료 버전은 하루에 1번 중지될
rim08.tistory.com
위 방법대로 우선 마리아db를 배포한다. 그러면 주소 복사를 하고 워크벤치에서 등록 시킨 뒤 테이블이 불러와지는 것을 확인해야 한다.
그런데 여기서............. 대실수함
아니 난 그저 잘못 입력해서 그거 하나만 지우려고 했는데..... 실수로 아래 버튼 중에서 올 커넥션 삭제를 누른 거임........


와 이때 진짜 ㄹㅇ 죽고 싶었음...... 설마 진짜 테이블 다 삭제 된건가 그럼 내 졸작 데베도????
컨트롤 제트 무한 눌렀으나 안먹히고
와........그래서 머리 새하얘져서 덜덜 떨면서 검색해봤는데
다행히 연결 정보만 지운거고 테이블은 그대로 남아있다고 하길래,,, cmd창으로 확인해봤더니 진짜 남아있었음

다행입니다.............진짜로
하여튼 그래서 일단 다시 로컬 서버 연결부터 하고,,,(첫번째 네모칸)
그런데 연결 다 삭제해버려서 다른 클라우드 타입 연결한건 지워졌긴 한데... 어차피 옛날거라 안 써서 대충 넘어감...
하여튼 그리고 이번에 새로 연결할거 다시 만들음 (두번째 네모칸)
아 그리고 여기서도 원래 로컬 서버로 실행할 땐 user이런식으로 이름짜서 여기서도 그렇게 했다가 오류로 빠꾸먹음
그냥 정석대로 root로 지정해주었더니 성공했습니다 하하
아무튼 그렇게 마리아db까지 배포 완료했으면 이제 중요한 코드 연동하기!!
우선 당연히 깃허브에 이미 push까지 완료되어있는 상태여야 하고
그 상태에서 아래 사이트 순서 따라서 비공개 레포지토리 배포 해주면 된다
https://docs.cloudtype.io/guide/references/private-repo
비공개 Git 저장소 배포 - 클라우드타입 Docs
클라우드타입은 클라우드 기반 애플리케이션을 빠르게 개발하고 배포할 수 있는 클라우드 애플리케이션 플랫폼입니다.
docs.cloudtype.io
여기서 오류 안 나면 진짜 끝!!!
08. 결과
1. 메인 화면
현재 집에 있는 약들 등록한 상태고 소비기한 순으로 정렬되게 함

2. 상세 화면
아래 버튼까지 보이게 하느라 조금 축소했는데 하여튼 소비기한, 개수, 복용법 등 정보가 나타남

수정이나 삭제 버튼 누르면 비밀번호 검증

3. 등록/수정 화면
화면은 동일한데 수정으로 하면 기존 정보 불러와짐
기한은 옆에 달력 아이콘으로 편하게 선택 가능

4. 병원 찾기 화면
위치 허용을 안 하면 아래처럼 기본 서울 시청 주변으로 뜬다.
위치 허용하면 자기 집 주변 병원이 마커로 표시되면서 아래에 목록으로 정상 출력된다.

5. AI 진단 화면
처음 들어가면 예시와 함께 입력 폼이 나타난다.

입력하면 아래처럼 대략 15~30초 후 진단 결과가 출력된다. 무료 버전 모델인데 이정도면 괜찮은 것 같다.
현재 사용자가 가지고 있는 약 중에서 증상에 맞는 걸 추천해주고, 그 외에 다른 판매 약도 추천해준다. 또한 증상에 대해 분석해주며 비타민 영양제, 치료법, 병원 추천까지 해준다.
+ 모바일 화면


이렇게 개인 토이프로젝트 끝~!!!
어제 19시~23시에 거의 구현 완료하고 오늘 10시~12시에 보완+배포까지 완료했다!
목적부터가 집에서 쓰는 거라 이름도 닥터홈으로 했는데(ㅋㅋ) 만들자마자 가족들한테 사이트 보내줬더니
난 ai 진단이 핵심 포인트라 생각했는데 다들 약 관리 이야기만 함...ㅋㅋㅋㅠ 그리고 원래는 유통기한에다 정렬 기능 없음, 모바일 크기 안맞음 이었는데 보완점 말해줘서 고친거임 ㅎ.ㅎ 뭔가 다른 사람한테 배포하고 의견듣는건 처음인 것 같는데 재밌네요... 언젠가 진짜 유료 결제해서 서버 만들고 더 많은 사용자한테 배포해보고 싶네여
그리고 이번에 ai 모델도 빠르게 연동할 수 있었던건 다 졸작 덕분임... 아직 이것도 완성하진 않고 계속 팀원들이랑 진행중인데 여기서 ai 모델 써보면서 방법 익혔음 물론 졸작도 정말 몇달동안 하면서 엄청나게 많은 시행착오와 위기가 있었지만... 그래도 현재 70%정도는 된 것 같아서 뿌듯합니다... 나중에 무사히 심사 통과되면 그때 한번에 글 써야지//
암튼 이번 글은 여기서 끝~~~!!!
