-imageId 단독 조회 문제 발견부터 cineroomId 기반 접근 제어와 develop/stg 검증까지-
1. 적용 배경
이번 작업은 proms-image 프로젝트에서 이미지 리소스 접근 권한을 점검하는 과정에서 시작되었습니다. 처음에는 특정 화면에서 이미지가 정상적으로 보이는지, 업로드와 조회가 깨지지 않는지 정도를 확인하는 작업으로 생각했습니다. 그러나 실제 확인 과정에서 이미지의 소유 병원 정보와 요청자의 병원 정보가 일치하지 않아도 이미지가 조회될 수 있는 문제가 재현되었습니다.
proms-image는 공지사항, 병원 등록 이미지, 문진 이미지, AMIS 설문 서식 이미지 등 여러 기능에서 공통으로 사용되는 이미지 저장 및 조회 역할을 담당합니다. 그래서 단순히 한 화면에서 이미지를 잘 보여주는 것만으로는 충분하지 않았습니다. 이미지가 어느 병원 또는 어느 cineroom에 속한 리소스인지 확인하고, 요청자가 그 리소스를 볼 수 있는지까지 함께 검증해야 했습니다.
이번 글에서는 실제 업무에서 발견한 이미지 소유권 검증 누락 문제를 어떻게 재현했고, 어떤 코드 경로를 수정했으며, develop과 stg 환경에서 어떻게 검증했는지를 정리합니다. 내부 식별값이나 실제 서비스 경로는 설명에 필요한 범위에서만 사용하고, 핵심은 보안 이슈를 발견하고 보완한 과정에 두었습니다.
2. 문제 상황: imageId 단독 조회의 위험성
문제의 핵심은 이미지 조회 시 imageId만으로 리소스를 찾는 경로가 존재했다는 점입니다. imageId는 UUID 형태라 임의 추측이 쉽지는 않지만, 내부 응답이나 화면 HTML, 로그, 다른 API 응답을 통해 알 수 있는 값입니다. 따라서 유효한 토큰을 가진 사용자가 imageId를 알고 있다면, 본인 병원에 속하지 않은 이미지도 조회할 수 있는 위험이 있었습니다.
기존 구조를 단순화하면 다음과 같습니다.
// 기존 조회 흐름 예시
public ImageFile findImageFile(String imageId) {
return imageFileStore.retrieve(imageId);
}
public ImageFile retrieve(String imageId) {
return imageFileJpaRepository.findById(imageId)
.map(ImageFileJpo::toDomain)
.orElse(null);
}
이 구조에서는 DB의 image_file 테이블에 cineroom_id가 저장되어 있어도 실제 조회 조건에는 포함되지 않습니다. 즉, 이미지가 p-1-c-2 소유로 저장되어 있더라도 p-1-c-1 사용자가 imageId를 알고 요청하면 해당 이미지가 반환될 수 있습니다.
보안 관점에서 이것은 단순한 조회 버그가 아니라 리소스 소유권 검증 누락입니다. 인증된 사용자라 하더라도 모든 리소스에 접근할 수 있는 것은 아니며, 특히 병원 또는 조직 단위로 데이터가 분리되는 서비스에서는 리소스의 소유 범위를 반드시 함께 확인해야 합니다.
3. 문제를 발견한 과정
문제를 확인하기 위해 먼저 develop 환경에서 p-1-c-1 사용자로 공지사항 이미지를 업로드했습니다. 업로드 후 DB에서 해당 이미지가 image_file 테이블에 저장되었고, cineroom_id가 p-1-c-1로 들어간 것을 확인했습니다.
그 다음 보안 검증을 위해 해당 이미지 row의 cineroom_id만 p-1-c-2로 변경했습니다. 이 상태에서 같은 p-1-c-1 사용자로 동일 공지사항을 다시 조회했습니다. 기대한 동작은 이미지가 보이지 않거나 404로 차단되는 것이었습니다. 하지만 기존 develop 환경에서는 이미지가 그대로 표시되었습니다.
브라우저 캐시 가능성을 배제하기 위해 강제 새로고침을 수행했고, Network 탭에서 이미지 조회 요청이 200 OK로 응답되는 것도 확인했습니다. 요청 헤더의 X-Tenant-Id는 p-1-c-1이었고, DB상 이미지의 cineroom_id는 p-1-c-2였습니다. 이 결과를 통해 기존 로직이 요청자의 cineroomId와 이미지의 cineroomId를 비교하지 않는다는 점을 확실히 확인했습니다.
-- 재현을 위한 DB 변경 예시
update image_file
set cineroom_id = 'p-1-c-2'
where id = '테스트_image_id';
select id, cineroom_id, original_name, valid_yn
from image_file
where id = '테스트_image_id';
이 재현은 문제의 방향을 명확하게 만들어 주었습니다. 단순히 특정 화면에서 이미지가 잘못 보이는 문제가 아니라, imageId 단독 조회가 가능한 모든 경로를 찾아야 하는 문제였습니다.
4. 원인 분석: 조회 경로가 하나가 아니었습니다
처음에는 공지사항 이미지 조회 API만 수정하면 된다고 생각할 수 있었습니다. 하지만 proms-image는 여러 기능에서 공통으로 호출되고 있었고, 이미지 조회 방식도 하나가 아니었습니다. 대표적으로 imageId 기준 조회, imageId 목록 기준 조회, fileId + fileNo 기준 조회가 있었습니다.
공지사항이나 병원 등록 이미지처럼 imageId가 직접 사용되는 경로는 비교적 추적이 쉬웠습니다. 반면 문진 이미지와 AMIS 설문 서식 이미지는 fileId와 fileNo를 기준으로 기존 이미지를 재사용하는 경로가 있었습니다. 이 경로는 처음 수정 범위에서 빠지기 쉬웠고, 실제로 문진 불러오기 테스트 중 cineroom_id를 변경해도 이미지가 계속 조회되는 것을 확인하면서 추가 보완이 필요하다는 점을 알게 되었습니다.
원인 분석 결과, 크게 두 종류의 보완이 필요했습니다.
-
일반 이미지 조회/수정/삭제 경로에서 imageId 단독 조회를 제거하고 imageId + cineroomId 조건으로 조회해야 했습니다.
-
문진 이미지 메타 조회 경로에서 fileId + fileNo 단독 조회를 제거하고 cineroomId + fileId + fileNo 조건으로 조회해야 했습니다.
-
proms-image-client를 사용하는 proms-survey와 proms-amc-seoul-adapter도 새 요청 형태에 맞춰 cineroomId를 전달해야 했습니다.
5. 수정 방향: 요청 cineroomId와 리소스 cineroomId를 함께 검증하기
수정 방향은 단순했습니다. 이미지 리소스를 조회할 때 imageId 또는 fileId + fileNo만으로 찾지 않고, 항상 요청자의 cineroomId를 조회 조건에 포함하는 것입니다. 요청자의 cineroomId는 서버에서 관리하는 사용자 context에서 가져오도록 했습니다.
조회 조건을 바꾸면 타 cineroom에 속한 이미지는 DB에서 조회되지 않습니다. 이때 “권한 없음”과 “존재하지 않음”을 구분해서 알려주면 리소스 존재 여부가 노출될 수 있으므로, 타 cineroom 이미지 접근은 실제 없는 이미지와 동일하게 404 Not Found로 처리하는 방향을 선택했습니다.
// 수정 방향 예시
String cineroomId = requesterCineroomId();
ImageFile imageFile = imageFileLogic.findImageFile(imageId, cineroomId);
if (imageFile == null) {
throw new ImageResourceNotFoundException();
}
이 방식의 장점은 검증 책임이 명확하다는 점입니다. 프론트에서 넘어온 임의 값에 의존하지 않고, 서버가 인식한 요청자 context를 기준으로 리소스 접근 범위를 제한합니다. 또한 DB 조회 단계에서 차단되기 때문에 MinIO 같은 실제 파일 스토리지에 접근하기 전에 요청을 끊을 수 있습니다.
6. 일반 이미지 조회/수정/삭제 경로 보완
일반 이미지 경로에서는 repository, store, domain logic, feature load/flow 계층을 함께 수정했습니다. 핵심은 기존 findById 또는 findByIdIn 조회를 findByIdAndCineroomId, findByIdInAndCineroomId 조건으로 바꾸는 것이었습니다.
// Repository 조회 조건 추가 예시
Optional<ImageFileJpo> findByIdAndCineroomId(String id, String cineroomId);
List<ImageFileJpo> findByIdInAndCineroomId(Collection<String> ids, String cineroomId);
조회 로직에서는 요청자의 activeCineroomId를 가져온 뒤, 해당 값과 imageId를 함께 전달했습니다. 수정/삭제 경로도 동일한 기준을 적용했습니다. 단순 조회만 막고 수정/삭제는 imageId만으로 처리하면 여전히 타 리소스를 변경하거나 삭제할 수 있기 때문입니다.
// Domain logic 예시
@Transactional(readOnly = true)
public ImageFile findImageFile(String imageId, String cineroomId) {
return imageFileStore.retrieveByIdAndCineroomId(imageId, cineroomId);
}
@Transactional(readOnly = true)
public List<ImageFile> findByIdIn(Collection<String> ids, String cineroomId) {
return imageFileStore.retrieveByIdInAndCineroomId(ids, cineroomId);
}
이 과정에서 기존에 존재하던 시스템 기본 이미지 fallback 경로는 주의해서 다루었습니다. 모든 경로에 무조건 context 검증을 넣으면 템플릿, 시스템 기본 이미지, 외부 또는 공용 호출 경로가 영향을 받을 수 있기 때문입니다. 따라서 일반 사용자 이미지 조회/수정/삭제 경로를 중심으로 수정하고, 공용 템플릿 경로는 실제 호출 의도와 영향 범위를 구분했습니다.
7. activeCineroomId가 없는 요청에 대한 방어 처리
테스트 중 별도의 문제도 발견했습니다. 브라우저의 로그인 또는 tenant context가 꼬인 상태에서 이미지 업로드를 요청하면 X-Tenant-Id가 정상적으로 생성되지 않고, 서버 context에서 activeCineroomId가 비어 있을 수 있었습니다. 이때 기존 코드는 Optional.get()을 바로 호출하면서 NoSuchElementException이 발생했고, 결과적으로 500 Internal Server Error가 내려갔습니다.
이 문제는 H-2 소유권 검증과 직접 같은 문제는 아니지만, 같은 작업 중 발견한 방어 처리 대상이었습니다. 사용자가 이해하기 어려운 500 오류 대신, 병원 선택 정보가 확인되지 않는다는 명확한 메시지를 내려주도록 전용 예외를 추가했습니다.
public class ActiveCineroomNotFoundException extends RuntimeException {
public ActiveCineroomNotFoundException() {
super("병원 선택 정보가 확인되지 않습니다. 다시 로그인 후 이용해 주세요.");
}
}
private String requesterCineroomId() {
PromsUserContext context = StageContextHolder.getContext();
if (context == null || context.getActiveCineroomId().isEmpty()) {
throw new ActiveCineroomNotFoundException();
}
return context.getActiveCineroomId().get();
}
예외 처리는 proms-image 전용 handler에서 처리하도록 했습니다. common-core의 공통 예외 처리 로직을 수정하면 다른 서비스까지 영향이 갈 수 있으므로, proms-image에서 필요한 예외만 먼저 잡도록 구성했습니다.
8. 문진 이미지 fileId + fileNo 조회 경로 보완
공지사항과 병원 등록 이미지는 imageId 기반 조회를 막으면 해결되었습니다. 하지만 문진 불러오기에서는 문제가 한 번 더 드러났습니다. 문진 이미지 조회 경로는 imageId가 아니라 fileId와 fileNo를 기준으로 기존 이미지를 찾고 있었습니다. 이 경우 image_file.cineroom_id를 p-1-c-2로 바꿔도 fileId + fileNo가 일치하면 기존 row가 재사용될 수 있었습니다.
이를 해결하기 위해 proms-image-client의 ClientFindImageByFileIdAndFileNoQuery에 cineroomId 필드를 추가하고, 서버 조회 조건도 cineroomId + fileId + fileNo로 변경했습니다.
public class ClientFindImageByFileIdAndFileNoQuery {
private String cineroomId;
private String fileId;
private String fileNo;
}
// Repository 조회 조건 추가 예시
Optional<ImageFileJpo> findByCineroomIdAndFileIdAndFileNo(
String cineroomId,
String fileId,
String fileNo
);
이 변경은 proms-image만 수정해서 끝나는 작업이 아니었습니다. proms-image-client를 사용하는 서비스들도 새로운 요청 형태에 맞춰 cineroomId를 넘겨야 했습니다. 실제 영향 범위를 확인한 결과 proms-survey와 proms-amc-seoul-adapter가 해당 client를 사용하고 있었고, 두 서비스 모두 수정이 필요했습니다.
proms-survey에서는 문진 상세 조회 과정에서 targetCineroomId를 함께 전달하도록 변경했습니다. proms-amc-seoul-adapter에서는 AMIS 설문 서식 조회 과정에서 병원 cineroomId를 함께 전달하도록 변경했습니다.
// proms-survey 호출부 예시
imageLoadProxyService.findImageByFileIdAndFileNo(
targetCineroomId,
fileId,
fileNo
);
// proms-amc-seoul-adapter 호출부 예시
String cineroomId = HospitalTenant.CINEROOM.getValue();
imageLoadProxyService.findImageByFileIdAndFileNo(
cineroomId,
vo.getImageFileId(),
vo.getImageFileNo()
);
9. client 모듈 배포와 연동 서비스 영향 범위
이번 수정에서 가장 조심해야 했던 부분은 proms-image-client 배포였습니다. proms-survey와 proms-amc-seoul-adapter는 Nexus에 배포된 com.proms:proms-image-client 라이브러리를 사용하고 있었습니다. 따라서 client DTO의 필드와 생성자가 바뀌면, 해당 client 버전을 사용하는 서비스들의 컴파일에도 영향을 줄 수 있었습니다.
기존 버전을 덮어쓰는 방식은 선택하지 않았습니다. 같은 Nexus를 main과 develop이 함께 바라보고 있었기 때문에, 기존 artifact 내용을 바꿔버리면 의도하지 않은 서비스가 새 DTO를 받게 될 수 있었습니다. 그래서 proms-image-client의 baseVersion을 1.0.4로 올리고, release 버전이 Nexus에 정상 배포되었는지 확인한 뒤, proms-survey와 proms-amc-seoul-adapter에서 해당 버전을 명시적으로 사용하도록 변경했습니다.
// 연동 서비스 build.gradle 예시
implementation 'com.proms:proms-image-client:1.0.4'
배포 전에는 각 저장소에서 호출부를 검색했습니다. findImageByFileIdAndFileNo(fileId, fileNo) 형태의 2개 인자 호출이 남아 있으면 새 client 적용 시 컴파일 오류가 발생하거나, 보안 검증이 누락될 수 있기 때문입니다.
rg "findImageByFileIdAndFileNo\(" -n .
rg "new ClientFindImageByFileIdAndFileNoQuery" -n .
rg "proms-image-client" -n .
검색 결과를 기준으로 proms-survey와 proms-amc-seoul-adapter의 호출부를 모두 3개 인자로 변경했고, build.gradle에서 proms-image-client 1.0.4를 사용하도록 정리했습니다.
10. develop 및 stg 검증 결과
수정 후 develop 환경과 stg 환경에서 순차적으로 검증했습니다. 검증은 단순 정상 조회뿐 아니라, DB에서 테스트 대상 이미지의 cineroom_id를 다른 병원 값으로 변경한 뒤 조회가 차단되는지 확인하는 방식으로 진행했습니다. 모든 DB 변경은 id 기준으로 1건만 수행했고, 테스트 후 즉시 원복했습니다.
10.1 공지사항 이미지 검증
공지사항 이미지는 p-1-c-1 사용자로 업로드한 뒤 정상 표시되는 것을 확인했습니다. 이후 해당 이미지 row의 cineroom_id를 p-1-c-2로 변경하고 동일 사용자로 조회했을 때 이미지가 표시되지 않는 것을 확인했습니다. 원복 후에는 다시 정상 표시되었습니다.
10.2 병원 등록 이미지 검증
병원 등록 또는 tenant 이미지도 동일한 방식으로 검증했습니다. 정상 조회를 먼저 확인한 뒤, image_file.cineroom_id를 p-1-c-2로 변경했습니다. 그 결과 p-1-c-1 사용자 기준으로 이미지가 조회되지 않았고, 다시 p-1-c-1로 원복하자 이미지가 정상 조회되었습니다.
10.3 proms-survey 문진 불러오기 이미지 검증
proms-survey에서는 문진 불러오기 화면에서 문진에 포함된 이미지가 정상 조회되는지 먼저 확인했습니다. 이후 해당 이미지의 cineroom_id를 p-1-c-2로 변경하자 p-1-c-1 사용자 기준 문진 조회에서 해당 이미지가 표시되지 않았습니다. 원복 후에는 다시 정상 조회되었습니다. 이를 통해 문진 이미지 조회 경로에서도 cineroomId 조건이 적용된 것을 확인했습니다.
10.4 proms-amc-seoul-adapter AMIS 설문 서식 이미지 검증
proms-amc-seoul-adapter의 경우 화면에서 직접 확인하기 어려워 Postman으로 AMIS 설문 서식 조회 API를 호출했습니다. 이 경로는 이미지가 없으면 AMIS 원본 파일을 다시 가져와 새 이미지 row를 생성할 수 있습니다. 따라서 “이미지가 안 보이는지”가 아니라 “p-1-c-2로 바꾼 기존 이미지 row가 재사용되는지”를 기준으로 검증했습니다.
테스트 결과 기존 p-1-c-1 이미지 row의 cineroom_id를 p-1-c-2로 변경한 뒤 동일 fileId + fileNo로 다시 조회했을 때, 기존 row는 재사용되지 않았습니다. 대신 p-1-c-1 기준의 새 이미지 row가 생성되었습니다. 이는 타 cineroom 이미지가 재사용되지 않고 요청 cineroom 기준으로 다시 처리된 것이므로 정상 동작으로 판단했습니다. 테스트 후 새로 생성된 row는 정리하고 기존 row는 원복했습니다.
-- adapter 검증 시 판단 기준 예시
-- OLD_IMAGE_ID: 기존 row, cineroom_id를 p-1-c-2로 변경
-- 동일 API 재호출 후 OLD_IMAGE_ID가 응답이나 재사용 경로에 나타나면 실패
-- OLD_IMAGE_ID가 재사용되지 않고 NEW_IMAGE_ID가 p-1-c-1로 생성되면 정상
select id, cineroom_id, file_id, file_no, original_name, valid_yn, registered_on
from image_file
where file_id = '테스트_file_id'
and file_no = '테스트_file_no'
order by registered_on desc;
10.5 Clinical 이미지 검증 상태
의료진-환자 Clinical 이미지는 당시 화면 접근이 제한되어 실제 화면 기준 테스트는 수행하지 못했습니다. 다만 코드 기준으로 공지사항, 문진 이미지와 동일하게 cineroomId 기반 조회 로직이 적용된 것을 확인했습니다. 향후 화면 접근이 가능해지면 실제 업로드, 조회, 타 cineroom 차단 여부를 추가 검증할 예정입니다.
11. 적용하면서 배운 점
첫 번째로 배운 점은 인증과 인가를 분리해서 봐야 한다는 것입니다. 사용자가 유효한 토큰을 가지고 있다는 사실은 “로그인한 사용자”라는 의미이지, 모든 이미지 리소스에 접근할 수 있다는 의미는 아닙니다. 이번 문제는 인증은 되어 있었지만 리소스 소유권 검증이 빠진 사례였습니다.
두 번째로 배운 점은 공통 모듈의 영향 범위를 반드시 확인해야 한다는 것입니다. proms-image-client는 여러 서비스가 함께 사용하는 라이브러리였습니다. 따라서 client DTO를 변경하면 proms-image 서버뿐 아니라 proms-survey, proms-amc-seoul-adapter 같은 호출 서비스도 함께 수정해야 했습니다. 단일 프로젝트 내부 수정처럼 보였지만 실제로는 다중 서비스 연동 변경이었습니다.
세 번째로 배운 점은 테스트 기준을 기능별로 다르게 잡아야 한다는 것입니다. 공지사항이나 병원 등록 이미지는 “이미지가 보이지 않아야 한다”가 맞는 검증 기준이었습니다. 반면 AMIS 설문 서식 이미지는 원본 파일 fallback으로 새 이미지가 생성될 수 있었기 때문에 “기존 타 cineroom row가 재사용되지 않아야 한다”가 더 정확한 기준이었습니다.
네 번째로 배운 점은 DB를 직접 조작하는 보안 검증에서는 원복 절차가 테스트만큼 중요하다는 것입니다. develop과 stg에서 테스트할 때는 항상 대상 row를 먼저 확인하고, id 기준으로 1건만 변경하고, 테스트가 끝나면 즉시 원복했습니다. 새 row가 생성되는 케이스에서는 테스트 row를 삭제하거나 무효 처리하여 환경이 오염되지 않도록 했습니다.
마지막으로, 작은 로그 제거도 배포 관점에서는 확인 대상이 될 수 있다는 점을 다시 느꼈습니다. ImageIoConfig의 System.out.println 제거처럼 기능 로직과 직접 관련 없는 수정은 전체 회귀 테스트가 필요하지는 않았지만, 새 pod가 기동될 때 해당 표준 출력 로그가 더 이상 나오지 않는지는 확인할 수 있었습니다.
12. 마무리
이번 작업은 단순히 이미지가 정상 표시되는지 확인하는 수준에서 시작했지만, 실제로는 이미지 리소스 소유권 검증을 전반적으로 점검하는 작업이 되었습니다. 처음에는 공지사항 이미지에서 문제가 재현되었고, 이후 문진 불러오기와 AMIS 설문 서식 조회 경로까지 확인하면서 imageId 기반 조회뿐 아니라 fileId + fileNo 기반 재사용 경로도 함께 보완해야 한다는 것을 알게 되었습니다.
최종적으로 proms-image에서는 imageId 또는 fileId + fileNo 단독 조회를 제거하고 cineroomId 조건을 포함하도록 수정했습니다. proms-image-client는 1.0.4로 배포했고, 이를 사용하는 proms-survey와 proms-amc-seoul-adapter에서도 cineroomId를 전달하도록 변경했습니다. develop과 stg 환경에서는 공지사항, 병원 등록, 문진 불러오기, AMIS 설문 서식 조회에 대해 정상 조회와 타 cineroom 차단 또는 미재사용 동작을 확인했습니다.
이번 경험을 통해 보안 이슈는 코드 한 줄만 보고 판단하기 어렵다는 점을 배웠습니다. 같은 이미지 리소스라도 화면, API, 저장 방식, fallback 로직에 따라 접근 경로가 달라질 수 있습니다. 따라서 보안 수정은 “문제가 발생한 화면”만 고치는 것이 아니라, 같은 리소스에 도달하는 모든 경로를 추적하고, 각 경로에 맞는 검증 기준을 세워야 합니다.
앞으로 비슷한 공통 리소스 조회 기능을 구현할 때는 처음부터 리소스 소유자, 요청자 context, 조회 조건, 실패 응답 정책, client 영향 범위까지 함께 확인하는 습관을 가져야겠다고 느꼈습니다. 이번 작업은 이미지 보안 이슈를 해결한 경험이면서, 동시에 MSA 환경에서 공통 client 변경과 다중 서비스 검증을 어떻게 관리해야 하는지 배운 사례였습니다.
부록. 검증 체크리스트
-
공지사항 이미지: 정상 조회, 타 cineroom 변경 시 차단, 원복 후 정상 조회를 확인했습니다.
-
병원 등록 이미지: 정상 조회, 타 cineroom 변경 시 차단, 원복 후 정상 조회를 확인했습니다.
-
proms-survey 문진 이미지: cineroom_id 변경 시 미표시, 원복 후 정상 표시를 확인했습니다.
-
proms-amc-seoul-adapter AMIS 설문 서식 이미지: 타 cineroom row가 재사용되지 않고 요청 cineroom 기준으로 새 row가 생성되는 것을 확인했습니다.
-
proms-image-client: 1.0.4 release 버전이 Nexus에 배포된 것을 확인했습니다.
-
proms-survey와 proms-amc-seoul-adapter: proms-image-client 1.0.4 사용 및 3개 인자 호출 형태를 확인했습니다.
-
develop 및 stg DB에서 변경한 테스트 데이터는 원복했습니다.
wade