보호자–환자 연결, 통화 기록 분석, 감정 리포트, 리마인더와 알림을 제공하는 치매 환자 케어 서비스
- GitHub : https://github.com/Alzheimer-dinger
- API 문서(Swagger) : https://api.alzheimerdinger.com/swagger-ui/index.html#/
프로젝트 소개 | 팀원 구성 | 기술 스택 | 저장소·브랜치 전략·구조 | 개발 기간·작업 관리 | 신경 쓴 부분 | 페이지별 기능 | 주요 API
본 프로젝트는 치매 환자와 보호자를 위한 AI 동반 케어 웹앱입니다. 환자는 앱에서 인공지능과 실시간 대화(음성/자막)로 일상을 공유하고, 보호자는 연결 계정을 통해 심리 상태와 이상 징후를 모니터링합니다. 하루하루 축적되는 대화·활동 데이터를 분석해 일·주·월 단위 종합 리포트 (감정 타임라인, 참여도, 평균 통화시간, 위험 지표)를 제공하여 세심한 돌봄 계획 수립을 돕습니다.
- 원클릭 통화(대기 → 진행 → 종료), 실시간 자막/응답
- RAG 메모리로 개인 맥락 유지, 토큰 효율 최적화
- 보호자–환자 관계 관리(요청/승인/해제) 및 리마인더/알림
- PWA/FCM 기반 푸시 알림, 웹 대시보드로 리포트 열람
- 운영/모니터링: Micrometer + Prometheus + Grafana
|
정장우 팀 리더 · 백엔드 주요 도메인 · 인프라 구축 |
김경규 백엔드 도메인 · 인증/인가 시스템/인프라 설계 |
박영두 백엔드 도메인 · 인프라 구축 · CI/CD · 모니터링 |
노예원 프론트 UI/UX · 통화 WebSocket · CD · FCM |
|
김효신 프론트 UI/UX · API 연동 · 상태관리 |
서현교 AI 아이디어 · RAG 메모리 · 분석 리포트 |
강민재 AI 실시간 통화 · 감정 분석·요약 |
전체 스펙은 Swagger에서 확인: https://api.alzheimerdinger.com/swagger-ui/index.html#/
| Method | Endpoint | 설명 | 인증 |
|---|---|---|---|
| POST | /api/users/sign-up | 회원가입(Guardian/Patient, 선택: 환자코드) | ❌ |
| POST | /api/users/login | 로그인(JWT Access/Refresh 발급, FCM 토큰 접수) | ❌ |
| DELETE | /api/users/logout | 로그아웃(토큰 무효화) | ✅ |
| POST | /api/token | 토큰 재발급(refreshToken 쿼리) | ✅ |
| GET | /api/users/profile | 프로필 조회 | ✅ |
| PATCH | /api/users/profile | 프로필 수정(이름/성별/비밀번호) | ✅ |
| GET | /api/images/profile/upload-url | GCS Presigned 업로드 URL 발급(extension) | ✅ |
| POST | /api/images/profile | 업로드 파일을 프로필 이미지로 적용(fileKey) | ✅ |
| POST | /api/relations/send | 관계 요청 전송(patientCode) | ✅ |
| POST | /api/relations/resend | 만료 요청 재전송(relationId) | ✅ |
| PATCH | /api/relations/reply | 관계 요청 응답(relationId, status) | ✅ |
| GET | /api/relations | 관계 목록 조회 | ✅ |
| DELETE | /api/relations | 관계 해제(relationId) | ✅ |
| GET | /api/reminder | 리마인더 조회 | ✅ |
| POST | /api/reminder | 리마인더 등록(fireTime, status) | ✅ |
| GET | /api/transcripts | 통화 기록 목록(요약) | ✅ |
| GET | /api/transcripts/{sessionId} | 통화 기록 상세(요약/대화 로그) | ✅ |
| GET | /api/analysis/report/latest | 최근 분석 리포트(periodEnd, userId) | ✅ |
| GET | /api/analysis/period | 기간별 감정 분석(start, end, userId) | ✅ |
| GET | /api/analysis/day | 일별 감정 분석(date, userId) | ✅ |
| POST | /api/feedback | 피드백 저장(rating, reason) | ✅ |
참고: 실시간 통화(음성/자막)은 클라이언트 ↔ AI 서버(WebSocket/Streaming) 연결을 통해 처리되며, 백엔드는 세션/기록/리포트 API를 제공합니다.
GitHub : https://github.com/Alzheimer-dinger
main— 배포용 안정 브랜치. 태깅(vX.Y.Z) 후 배포.develop— 통합 개발 브랜치. 기능/버그 픽스 머지 대상.feature/<scope>-<short-desc>— 기능 단위 작업. 완료 시 PR →develop.hotfix/<issue>— 긴급 수정. PR →main및develop양쪽 반영.release/<version>— 릴리즈 준비(버전, 문서, 마이그레이션) 후main병합.
- PR 템플릿 사용: 배경/변경점/테스트/스크린샷/체크리스트 포함
- 리뷰 1명 이상 승인(🚦 최소 1 Approve), CI 통과 필수
- 라벨:
feature,fix,refactor등
feat(auth): add refresh token rotation
fix(api): handle null imageUrl in profile response
refactor(ui): split ReportChart into small components
docs(readme): add tech stack badges
chore(ci): bump node to 20.x in workflow
/
├─ BE/
│ ├─ build.gradle
│ ├─ src/main/java/opensource/alzheimerdinger/core
│ │ ├─ global/
│ │ └─ domain/
│ │ ├─ user/
│ │ ├─ image/
│ │ ├─ relation/
│ │ ├─ reminder/
│ │ ├─ transcript/
│ │ ├─ analysis/
│ │ └─ feedback/
│ └─ src/main/resources/
│
├─ FE/
│ ├─ package.json
│ └─ src/
│
└─infra/
├─ docker-compose.yml
├─ nginx/
├─ prometheus/
└─ grafana/
아래는 user 도메인의 대표 구성요소를 간단히 요약한 예시입니다.
전체 코드는 레포지토리에서 확인하세요.
1) DTO · Request
// LoginRequest.java
public record LoginRequest(
@Email @NotBlank String email,
@NotBlank String password,
@NotNull String fcmToken
) {}
// SignUpRequest.java
public record SignUpRequest(
@NotBlank String name,
@Email @NotBlank String email,
@NotBlank String password,
@NotNull Gender gender,
String patientCode
) {}
// UpdateProfileRequest.java
public record UpdateProfileRequest(
@NotBlank String name,
@NotNull Gender gender,
String currentPassword,
String newPassword
) {
@AssertTrue(message = "currentPassword is required when newPassword is provided")
public boolean isPasswordChangeValid() {
if (newPassword == null || newPassword.isBlank()) return true;
return currentPassword != null && !currentPassword.isBlank();
}
}
}
2) DTO · Response
// LoginResponse.java
public record LoginResponse(String accessToken, String refreshToken) {}
// ProfileResponse.java
public record ProfileResponse(
String userId,
String name,
String email,
Gender gender,
String imageUrl,
String patientCode
) {
public static ProfileResponse create(User user, String imageUrl) {
return new ProfileResponse(
user.getUserId(),
user.getName(),
user.getEmail(),
user.getGender(),
imageUrl,
user.getPatientCode()
);
}
}
3) Entity
// Gender.java
public enum Gender { MALE, FEMALE }
// Role.java
@Getter
public enum Role {
GUARDIAN("ROLE_GUARDIAN"),
PATIENT("ROLE_PATIENT");
private final String name;
Role(String name) { this.name = name; }
}
// User.java
@Entity
@Table(name = "users")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User extends BaseEntity {
@Id @Tsid
private String userId;
private String name;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
private String patientCode;
@Enumerated(EnumType.STRING)
private Gender gender;
public void updateRole(Role role) {
this.role = role;
}
public void updateProfile(String name, Gender gender, String encodedNewPassword) {
this.name = name;
this.gender = gender;
if (encodedNewPassword != null && !encodedNewPassword.isBlank()) {
this.password = encodedNewPassword;
}
}
}
4) Repository
// UserRepository.java
public interface UserRepository extends JpaRepository<User, String> {
@Query("select count(u) > 0 from User u where u.email = :email")
Boolean existsByEmail(@Param("email") String email);
@Query("select u from User u where u.email = :email")
Optional<User> findByEmail(@Param("email") String email);
@Query("select u from User u where u.patientCode = :patientCode")
Optional<User> findByPatientCode(@Param("patientCode") String patientCode);
}
5) Service (요약)
// UserService.java (발췌)
@Service
@RequiredArgsConstructor
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final ImageService imageService;
public boolean isAlreadyRegistered(String email) {
return userRepository.existsByEmail(email);
}
public User save(SignUpRequest req, String code) {
return userRepository.save(
User.builder()
.email(req.email())
.password(passwordEncoder.encode(req.password()))
.role(req.patientCode() == null ? Role.PATIENT : Role.GUARDIAN)
.patientCode(code)
.gender(req.gender())
.name(req.name())
.build()
);
}
public ProfileResponse findProfile(String userId) {
return userRepository.findById(userId)
.map(u -> ProfileResponse.create(u, imageService.getProfileImageUrl(u)))
.orElseThrow(() -> new RestApiException(_NOT_FOUND));
}
}
6) UseCase
// UpdateProfileUseCase.java (발췌)
@Service
@Transactional
@RequiredArgsConstructor
public class UpdateProfileUseCase {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final ImageService imageService;
@UseCaseMetric(domain = "user-profile", value = "update-profile", type = "command")
public ProfileResponse update(String userId, UpdateProfileRequest req) {
User user = userService.findUser(userId);
String encodedNewPassword = null;
if (req.newPassword() != null && !req.newPassword().isBlank()) {
boolean matches = passwordEncoder.matches(req.currentPassword(), user.getPassword());
if (!matches) {
log.warn("[UpdateProfile] password mismatch: userId={}", userId);
throw new RestApiException(_UNAUTHORIZED);
}
encodedNewPassword = passwordEncoder.encode(req.newPassword());
}
user.updateProfile(req.name(), req.gender(), encodedNewPassword);
return ProfileResponse.create(user, imageService.getProfileImageUrl(user));
}
}
// UserAuthUseCase.login(...) (발췌)
public LoginResponse login(LoginRequest req) {
User user = userService.findByEmail(req.email());
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new RestApiException(LOGIN_ERROR);
}
String at = tokenProvider.createAccessToken(user.getUserId(), user.getRole());
String rt = tokenProvider.createRefreshToken(user.getUserId(), user.getRole());
Duration exp = tokenProvider.getRemainingDuration(rt)
.orElseThrow(() -> new RestApiException(EXPIRED_MEMBER_JWT));
refreshTokenService.saveRefreshToken(user.getUserId(), rt, exp);
fcmTokenService.upsert(user, req.fcmToken());
return new LoginResponse(at, rt);
}
7) Controller
// AuthController.java (발췌)
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class AuthController {
private final UserAuthUseCase userAuthUseCase;
@PostMapping("/sign-up")
public BaseResponse<Void> signUp(@Valid @RequestBody SignUpRequest req) {
userAuthUseCase.signUp(req);
return BaseResponse.onSuccess();
}
@PostMapping("/login")
public BaseResponse<LoginResponse> login(@Valid @RequestBody LoginRequest req) {
return BaseResponse.onSuccess(userAuthUseCase.login(req));
}
@DeleteMapping("/logout")
public BaseResponse<Void> logout(HttpServletRequest request) {
userAuthUseCase.logout(request);
return BaseResponse.onSuccess();
}
}
// UserController.java (발췌)
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
@SecurityRequirement(name = "Bearer Authentication")
public class UserController {
private final UserProfileUseCase userProfileUseCase;
private final UpdateProfileUseCase updateProfileUseCase;
@GetMapping("/profile")
public BaseResponse<ProfileResponse> getProfile(@CurrentUser String userId) {
return BaseResponse.onSuccess(userProfileUseCase.findProfile(userId));
}
@PatchMapping("/profile")
public BaseResponse<ProfileResponse> updateProfile(
@CurrentUser String userId,
@Valid @RequestBody UpdateProfileRequest req
) {
return BaseResponse.onSuccess(updateProfileUseCase.update(userId, req));
}
}
// TokenController.java (발췌)
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/token")
@SecurityRequirement(name = "Bearer Authentication")
public class TokenController {
private final TokenReissueService tokenReissueService;
@PostMapping
public BaseResponse<TokenReissueResponse> reissue(
@RefreshToken String refreshToken,
@CurrentUser String userId
) {
return BaseResponse.onSuccess(tokenReissueService.reissue(refreshToken, userId));
}
}
| 기간 | 스프린트 목표 | 주요 산출물 |
|---|---|---|
| 2025-06-20 ~ 2025-07-03 (1~2주차) | 요구사항 정의 · API 명세 · DB 설계 | 요구사항 정의서, ERD, Swagger 초안 |
| 2025-07-04 ~ 2025-07-31 (3~6주차) | 핵심 기능·UI/UX 개발, RAG 구현, 프롬프트 엔지니어링 | FE 페이지/컴포넌트, BE 도메인/인증, RAG 서비스 |
| 2025-08-01 ~ 2025-08-14 (7~8주차) | 기능 통합·안정화 테스트 | E2E/통합 테스트, 버그픽스, 성능/보안 점검 |
| 2025-08-15 ~ 2025-08-21 (9주차) | 배포·모니터링·운영 | 릴리즈 노트, 대시보드, 알림 룰 |
- 이슈 추적: GitHub Issues (템플릿: bug/feature/chore)
- 칸반: GitHub Projects — Backlog → In Progress → In Review → Done
- WIP 제한: 인당 2개(리뷰 포함), 급한 이슈는 라벨
priority:high - 릴리즈: 주 1회 태깅(세맨틱 버저닝), 체인지로그 자동화
- 품질 게이트: CI 빌드/테스트/리포트, 린트·포맷·타입체크
환자와 AI가 음성으로 대화하고, 실시간 자막을 제공하는 통화 기능을 구현했습니다. 통화 전/중/후 상태를 명확히 분리하고, 오디오 스트림 처리와 스트리밍 응답을 안정적으로 연결합니다.
CallWaiting → CallActive → CallEnd (종료 후 요약/저장)
- CallWaiting: 장치/권한 체크(마이크), 서버 연결 준비, 상태 표시
- CallActive: 실시간 자막(부분/최종), 발화/응답 타임라인, 음소거/종료 버튼
- CallEnd: 통화 요약 노출, 저장/이탈 동작 분기
useAudioStream훅으로 발화 감지(VAD) 및 마이크 스트림 수집WebAudio/MediaDevicesAPI 사용, 입력 레벨 모니터링 및 일시정지/재개- 샘플레이트/채널 정규화 → 네트워크 전송 포맷으로 인코딩(스트리밍)
- WebSocket 기반 양방향 스트리밍: 오디오 업스트림, 자막/오디오 다운스트림
- 부분/최종 자막 구분 렌더링(부분 갱신 → 최종 확정)
- 연결 신뢰성: 핑/퐁 헬스체크, 지수적 재시도, 일시 네트워크 단절 복구
- 에러/예외 처리: 인증 오류, 장치 접근 실패, 모델 과부하 시 사용자 가이드
- 리소스 정리: 트랙 stop, 소켓 close, 메모리 해제(종료/이탈 시)
일간/기간 종합 관점에서 감정 및 이용 지표를 시각화합니다. 날짜/기간 선택에 따라 API 파라미터를 구성하고, 전처리된 데이터로 그래프/지표 컴포넌트를 렌더링합니다.
- 날짜 선택 + 월간 이모지 캘린더로 하루 흐름 빠른 탐색
- 감정 계산 로직: 대화 로그 기반 점수 산출(행복/슬픔/분노/놀람/권태 등)
- 원형 스코어 게이지로 당일 상태를 직관적으로 표현
- 기간 선택: 1주 / 1달 / 사용자 지정 범위
- 감정 타임라인: 날짜별 점수 추세(Recharts 라인/에어리어 차트)
- 참여도/평균 통화시간/위험도 계산 및 카드 지표로 요약
- EndDate 기준 종합 보고서: 선택 범위의 말일을 기준으로 요약 문구/지표 확정
- 통화 중: 마이크 권한 → 오디오 스트림(WebSocket) 전송 → AI 응답(오디오/자막) 수신
- 통화 후: 세션 요약/대화 로그 서버 기록 → 분석 API가 집계/리포트 생성
- 리포트 조회: 사용자/연결 대상 식별 → 쿼리 파라미터 구성 → 일간/종합 API 호출 → 시각화
Splash · 온보딩
- 앱 로드시 스플래시 → 로그인 상태에 따라 라우팅
- 간단 소개/권한 안내(마이크, 푸시)
로그인/회원가입
- 이메일·비밀번호 유효성 검사, 오류 메시지 인라인 표시
- 회원가입 후 프로필 초기 설정(이름/성별/환자코드 옵션)
- JWT 발급(Access/Refresh), FCM 토큰 등록
프로필
- 내 프로필: 이미지/이름/성별/비밀번호 수정, 판매 영역은 미사용
- 관계(보호자-환자) 상태 표시
관계 관리
- 환자코드로 요청, 만료 시 재전송, 승인/거절
- 관계 목록/해제, 상태(REQUESTED/APPROVED 등) 표시
통화(실시간 AI)
- 흐름:
CallWaiting → CallActive → End - 마이크 권한, 발화 감지(useAudioStream), WebSocket/Streaming
- 실시간 자막/응답, 종료 후 기록 저장
통화 기록(Transcripts)
- 목록: 세션ID/제목/일시/지속시간 요약
- 상세: 요약/대화 로그, 페이징/검색
분석 리포트
- 일간: 날짜 선택, 월간 이모지 캘린더, 감정 점수, 원형 스코어
- 종합: 기간(1주/1달/사용자 지정) 선택, 감정 타임라인, 참여도/평균 통화시간/위험도
리마인더
- 알림 시각·상태 등록/조회(ACTIVE/INACTIVE)
- PWA/FCM 기반 푸시
설정/로그아웃
- 세션 종료(토큰 무효화), 보안/알림 설정
피드백
- 평점(예: VERY_LOW~)과 사유 저장, 운영 개선에 활용