Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ jobs:
IMAGE: ${{ needs.build-docker.outputs.image-tag }}
run: |
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -p 8082:8082 --env-file /opt/carecode/.env $IMAGE"
"mkdir -p /opt/carecode/uploads-staging && docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -v /opt/carecode/uploads-staging:/app/uploads -p 8082:8082 --env-file /opt/carecode/.env $IMAGE"

- name: Run health check
if: steps.secrets.outputs.configured == 'true'
Expand Down Expand Up @@ -450,8 +450,14 @@ jobs:
echo "===== pull $IMAGE ====="
docker pull "$IMAGE"

# 업로드물은 컨테이너 밖에 둔다. 볼륨이 없으면 교체할 때마다 /app/uploads 가 컨테이너와
# 함께 사라진다 — 사용자가 올린 진단서·프로필 이미지가 배포마다 전부 날아간다는 뜻이다.
# (여러 인스턴스로 늘릴 때는 S3 구현체로 바꿔야 한다. 이슈 #49)
UPLOADS_DIR=/opt/carecode/uploads
mkdir -p "$UPLOADS_DIR"

run_app() {
docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null
docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null
}

wait_healthy() {
Expand Down
4 changes: 2 additions & 2 deletions archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ health → user 리포지토리: Constructor <com.carecode.domain.health.app.Hea
health → user 리포지토리: Constructor <com.carecode.domain.health.service.ChildService.<init>(com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.ChildMapper, com.carecode.domain.user.app.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (ChildService.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.GrowthChartService.<init>(com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.app.CurrentUserFacade)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (GrowthChartService.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.HealthActorResolver.<init>(com.carecode.domain.user.repository.UserRepository)> has parameter of type <com.carecode.domain.user.repository.UserRepository> in (HealthActorResolver.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.HealthService.<init>(com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (HealthService.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.HealthService.<init>(com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.core.storage.FileStorageService, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (HealthService.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.SiblingOverviewService.<init>(com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.app.CurrentUserFacade)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (SiblingOverviewService.java:0)
health → user 리포지토리: Constructor <com.carecode.domain.health.service.VaccinationScheduleService.<init>(com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.repository.ChildRepository)> has parameter of type <com.carecode.domain.user.repository.ChildRepository> in (VaccinationScheduleService.java:0)
health → user 리포지토리: Field <com.carecode.domain.health.app.HealthFacade.userRepository> has type <com.carecode.domain.user.repository.UserRepository> in (HealthFacade.java:0)
Expand All @@ -175,7 +175,7 @@ health → user 리포지토리: Method <com.carecode.domain.health.service.Chil
health → user 리포지토리: Method <com.carecode.domain.health.service.GrowthChartService.requireOwnedChild(java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (GrowthChartService.java:85)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthActorResolver.resolve(java.lang.String)> calls method <com.carecode.domain.user.repository.UserRepository.findById(java.lang.Object)> in (HealthActorResolver.java:70)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthActorResolver.resolve(java.lang.String)> calls method <com.carecode.domain.user.repository.UserRepository.findByUserId(java.lang.String)> in (HealthActorResolver.java:64)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.createHealthRecord(com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest, java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (HealthService.java:72)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.createHealthRecord(com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest, java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (HealthService.java:74)
health → user 리포지토리: Method <com.carecode.domain.health.service.SiblingOverviewService.getOverview()> calls method <com.carecode.domain.user.repository.ChildRepository.findByUserIdOrderByCreatedAtDesc(java.lang.Long)> in (SiblingOverviewService.java:44)
health → user 리포지토리: Method <com.carecode.domain.health.service.VaccinationScheduleService.generateScheduleForChild(java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (VaccinationScheduleService.java:32)
notification → user 리포지토리: Constructor <com.carecode.domain.notification.app.NotificationFacade.<init>(com.carecode.domain.notification.service.NotificationService, com.carecode.domain.notification.service.NotificationPreferenceService, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.domain.notification.repository.NotificationPreferenceRepository)> has parameter of type <com.carecode.domain.user.repository.UserRepository> in (NotificationFacade.java:0)
Expand Down
4 changes: 4 additions & 0 deletions docs/features/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다.
기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90
- 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던
`carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다
- **업로드물은 `/opt/carecode/uploads` 에 둡니다.** 배포가 그 디렉터리를 컨테이너의
`/app/uploads` 로 마운트합니다. 볼륨이 없으면 교체할 때마다 업로드물이 컨테이너와 함께
사라집니다 — 사용자가 올린 진단서·프로필 이미지가 **배포마다 전부 날아갑니다.**
(여러 인스턴스로 늘릴 때는 S3 구현체가 필요합니다. 이슈 #49)
- **MariaDB 와 Redis 는 서버에 미리 있어야 합니다.** 배포는 앱 컨테이너만 교체합니다.
Redis 는 운영에서 선택이 아닙니다 — 리프레시 토큰 폐기, 레이트 리밋, 이메일 인증코드가 여기에 있습니다.
준비 명령은 [1GB 인스턴스(프리티어)에 올리기](#1gb-인스턴스프리티어에-올리기) 에 있습니다
Expand Down
6 changes: 6 additions & 0 deletions docs/operations/deployment-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ sudo usermod -aG docker ubuntu # sudo 없이 docker 를 쓸 수 있어야 한
free -h # 스왑 확인 — 1GB 인스턴스는 스왑 2GB 가 필요하다
```

```bash
# 업로드물을 담을 디렉터리. 배포가 컨테이너의 /app/uploads 로 마운트한다.
sudo mkdir -p /opt/carecode/uploads
sudo chown 1000:1000 /opt/carecode/uploads # 컨테이너의 carecode 사용자
```

스왑이 없으면 기동 중 OOM 으로 죽습니다. 설정 명령과 근거(메모리 실측 포함)는
[`docs/features/operations.md` 의 "1GB 인스턴스(프리티어)에 올리기"](../features/operations.md#1gb-인스턴스프리티어에-올리기)
를 보세요.
Expand Down
25 changes: 22 additions & 3 deletions src/main/java/com/carecode/core/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,30 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Value("${app.storage.public-base-url:/files}")
private String publicBaseUrl;

/** 업로드된 파일을 정적 리소스로 서빙한다. (S3 로 전환하면 이 매핑 대신 버킷 URL 을 그대로 쓰면 된다.) */
/** 정적으로 열어 두는 하위 디렉터리. 이 밖의 업로드물은 인증·소유권을 거치는 경로로만 나간다. */
private static final String PUBLIC_SUBDIRECTORY = "profile-images";

/**
* 업로드된 파일 중 <b>프로필 이미지만</b> 정적 리소스로 서빙한다.
*
* <p>전에는 업로드 루트 전체({@code /files/**})를 서빙했다. 보안 설정이
* {@code /files/profile-images/**} 만 공개로 두고 나머지는 인증을 요구했지만,
* <b>인증만으로는 부족했다</b> — 인증을 거치는 다운로드 경로
* ({@code GET /health/records/{recordId}/attachments/{id}/download})는 소유권까지 확인하는데,
* 정적 경로는 로그인한 아무나 통과시켰다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로
* 받아갈 수 있었다. 파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아니다.
*
* <p>프로필 이미지는 공개로 둔다. {@code <img src>} 로 불러가므로 인증 헤더를 붙일 수 없고,
* 원래 화면에 노출되는 값이다.
*
* <p>S3 로 전환하면 이 매핑 대신 버킷의 공개 프리픽스를 쓰고, 첨부는 서명 URL 로 내려준다.
* 그때도 <b>경계는 같다</b> — 첨부는 소유권을 확인한 뒤에만 주소가 나간다.
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String location = Paths.get(storageRoot).toAbsolutePath().normalize().toUri().toString();
registry.addResourceHandler(publicBaseUrl + "/**")
String location = Paths.get(storageRoot).resolve(PUBLIC_SUBDIRECTORY)
.toAbsolutePath().normalize().toUri().toString();
registry.addResourceHandler(publicBaseUrl + "/" + PUBLIC_SUBDIRECTORY + "/**")
.addResourceLocations(location)
.setCachePeriod(3600);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ public void delete(Long recordId, Long attachmentId) {
}

attachmentRepository.delete(attachment);

// 저장된 파일도 지운다. FileStorageService.delete 는 처음부터 있었지만 부르는 곳이
// 없어서, 첨부를 삭제해도 디스크에 파일이 남았다. 남은 파일은 조회할 길이 없으니 당장
// 새어 나가지는 않지만, 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에
// 남는다. 파기 의무를 생각하면 그게 문제다.
//
// 행을 먼저 지우는 순서다. 파일 삭제가 실패해도 사용자에게는 삭제된 것으로 보이고,
// 남은 파일은 가리키는 행이 없어 접근 경로가 없다. 반대 순서라면 파일은 사라졌는데
// 행이 남아 다운로드가 깨진다.
fileStorageService.delete(fileStorageService.toKey(attachment.getFileUrl()));
}

/** 건강기록 조회 + 소유권 검증. 건강기록은 민감정보이므로 본인 기록만 접근할 수 있어야 한다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.carecode.domain.user.entity.ConsentType;
import com.carecode.domain.user.service.ConsentGuard;
import com.carecode.core.annotation.LogExecutionTime;
import com.carecode.core.storage.FileStorageService;
import com.carecode.core.exception.CareCodeException;
import com.carecode.core.exception.CareServiceException;
import com.carecode.core.exception.ResourceNotFoundException;
Expand Down Expand Up @@ -46,6 +47,7 @@ public class HealthService {
private final HealthRecordRepository healthRecordRepository;
private final ConsentGuard consentGuard;
private final HealthRecordAttachmentRepository healthRecordAttachmentRepository;
private final FileStorageService fileStorageService;
/**
* 건강 기록을 자녀에 붙이려면 엔티티가 필요해 남겨 둔다. 조회·소유권 판단은
* {@link ChildDirectory} 가 하고, 여기서는 통과한 뒤 연관을 걸 때만 쓴다.
Expand Down Expand Up @@ -168,9 +170,25 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) {
HealthRecord record = healthRecordRepository.findById(recordId)
.orElseThrow(() -> new HealthRecordNotFoundException(recordId));
assertHealthRecordOwnedByUser(record, actorUserId);


// 엔티티의 cascade = ALL, orphanRemoval = true 는 첨부 "행" 만 지운다. 저장된 파일은
// 디스크에 남아서, 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에
// 남았다. 행이 사라지면 그 파일을 가리키는 것이 없어 지울 기회도 없어지므로, 지우기
// 전에 키를 모아 둔다.
List<String> storageKeys = healthRecordAttachmentRepository
.findByHealthRecordIdOrderByDisplayOrderAsc(recordId).stream()
.map(HealthRecordAttachment::getFileUrl)
.filter(url -> url != null && !url.isBlank())
.map(fileStorageService::toKey)
.toList();

healthRecordRepository.delete(record);
log.info("건강 기록이 삭제되었습니다: 기록ID={}", recordId);

// 행을 먼저 지운다. 파일 삭제가 실패해도 남은 파일을 가리키는 행이 없어 접근 경로가
// 없다. 반대 순서라면 파일은 사라졌는데 행이 남아 다운로드가 깨진다.
storageKeys.forEach(fileStorageService::delete);

log.info("건강 기록이 삭제되었습니다: 기록ID={}, 첨부파일={}건", recordId, storageKeys.size());
}

// ===== 아동 정보 관리 =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -115,6 +116,55 @@ void resolvesStorageKeyFromPublicUrl() {
}
}

@Nested
@DisplayName("삭제")
class Deletion {

/**
* 첨부를 지우면 저장된 파일도 지워야 한다.
*
* <p>{@code FileStorageService.delete(key)} 는 처음부터 있었지만 <b>부르는 곳이
* 없었다.</b> 그래서 첨부를 삭제해도 디스크에 파일이 남았다. 건강기록을 지울 때도
* 같다 — 엔티티의 {@code cascade = ALL, orphanRemoval = true} 가 첨부 <b>행</b>만
* 지운다.
*
* <p>남은 파일은 조회할 길이 없으니 당장 새어 나가지는 않지만, 민감정보(진단서·처방전)가
* "지웠다" 고 생각한 뒤에도 서버에 남는다. 열람권·파기 의무를 생각하면 그게 문제다.
*/
@Test
@DisplayName("첨부를 지우면 저장된 파일도 지운다")
void deletingAttachmentRemovesStoredFile() {
when(attachmentRepository.findById(5L)).thenReturn(Optional.of(attachment(5L, MY_RECORD)));

service.delete(MY_RECORD, 5L);

verify(attachmentRepository).delete(any(HealthRecordAttachment.class));
verify(fileStorageService).delete("health-records/2026/08/29/stored.png");
}

/** 소유권 확인이 먼저다. 남의 첨부로 파일을 지우게 하면 삭제가 공격 수단이 된다. */
@Test
@DisplayName("남의 기록이면 파일을 지우지 않는다")
void foreignRecordDeletesNothing() {
assertThatThrownBy(() -> service.delete(OTHERS_RECORD, 5L))
.isInstanceOf(HealthRecordNotFoundException.class);

verify(fileStorageService, never()).delete(anyString());
verify(attachmentRepository, never()).delete(any(HealthRecordAttachment.class));
}

@Test
@DisplayName("다른 기록에 달린 첨부 id 로는 지울 수 없다")
void crossRecordDeleteIsRejected() {
when(attachmentRepository.findById(7L)).thenReturn(Optional.of(attachment(7L, 12345L)));

assertThatThrownBy(() -> service.delete(MY_RECORD, 7L))
.isInstanceOf(ResourceNotFoundException.class);

verify(fileStorageService, never()).delete(anyString());
}
}

@Nested
@DisplayName("접근 제어")
class AccessControl {
Expand Down
Loading
Loading