Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.aibe.team2.domain.interview.controller;

import com.aibe.team2.domain.interview.dto.InterviewReportResponse;
import com.aibe.team2.domain.interview.dto.InterviewStartRequest;
import com.aibe.team2.domain.interview.dto.VoiceSessionResponse;
import com.aibe.team2.domain.interview.entity.InterviewSession;
Expand All @@ -8,6 +9,7 @@
import com.aibe.team2.domain.interview.repository.InterviewRepository;
import com.aibe.team2.domain.interview.service.ConversationManager;
import com.aibe.team2.domain.interview.service.InterviewManager;
import com.aibe.team2.domain.interview.service.InterviewReportService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand All @@ -23,7 +25,10 @@ public class InterviewController {

private final ConversationManager conversationManager;
private final InterviewManager interviewManager;
private final InterviewRepository interviewRepository; // 세션 조회를 위해 Repository 의존성 추가
private final InterviewRepository interviewRepository;

// [FR-INT-08] 리포트 조회를 위한 서비스 의존성 추가
private final InterviewReportService interviewReportService;

@PostMapping("/start")
public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewStartRequest request) {
Expand All @@ -47,32 +52,24 @@ public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewSta
public SseEmitter streamTextInterview(
@PathVariable Long sessionId,
@RequestParam String answer,
@RequestParam Long memberId, // 프론트엔드 연동용 로그인 유저 ID
@RequestParam Long memberId,
@RequestParam(required = false, defaultValue = "gemini-flash-latest") String modelVariant,
@RequestParam(required = false, defaultValue = "NORMAL") String interviewMode) {

// 리뷰 반영: 전용 private 메서드로 조회 및 권한 검증 위임 (코드 중복 제거 및 보안 강화)
// 리뷰 반영: 전용 private 메서드로 조회 및 권한 검증 위임 (코드 중복 제거)
InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId);

// [FR-INT-02] 상태 전이 로직
if (session.getStatus() == InterviewSessionStatus.CREATED) {
interviewManager.advanceStatus(sessionId, InterviewSessionStatus.IN_PROGRESS);
}

SseEmitter emitter = new SseEmitter(120000L);

try {
conversationManager.startTextStreaming(
sessionId,
answer,
modelVariant,
InterviewMode.from(interviewMode),
emitter
);
// 리뷰 반영: sessionId(Long) 대신 검증이 끝난 session 객체를 통째로 넘기도록 수정
conversationManager.startTextStreaming(session, answer, modelVariant, InterviewMode.from(interviewMode), emitter);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}

return emitter;
}

Expand All @@ -81,7 +78,7 @@ public VoiceSessionResponse startVoiceInterview(
@PathVariable Long sessionId,
@RequestParam Long memberId) {

// 두 번째 리뷰 반영: startVoiceInterview 엔드포인트 역시 동일한 검증 위임
// 리뷰 반영: 전용 private 메서드로 위임 (코드 중복 제거)
InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId);

if (session.getStatus() == InterviewSessionStatus.CREATED) {
Expand All @@ -96,16 +93,33 @@ public ResponseEntity<Void> endInterview(
@PathVariable Long sessionId,
@RequestParam Long memberId) {

// 세 번째 리뷰 반영: 면접 종료 엔드포인트 역시 동일한 검증 위임
// 리뷰 반영: 전용 private 메서드로 위임 (코드 중복 제거)
validateAndGetSessionOwnership(sessionId, memberId);

interviewManager.advanceStatus(sessionId, InterviewSessionStatus.DONE);
return ResponseEntity.ok().build();
}

// 보안 리뷰 및 팀원 피드백 반영: IDOR 취약점 방지 및 중복 로직 제거를 위한 전용 검증 메서드 (streamTextInterview, startVoiceInterview, endInterview 모든 엔드포인트에 공통 적용됨)
// 현재는 프론트엔드 연동을 위해 @RequestParam 으로 넘겨받은 memberId를 검증에 사용 중
// 추후 Spring Security 적용 시, 컨트롤러 파라미터를 삭제하고 @AuthenticationPrincipal SecurityContext에서 추출한 실제 로그인 사용자의 ID로 변경하여 완벽한 인가 처리를 수행
// [FR-INT-08] 면접 결과 리포트 조회 엔드포인트 추가
@GetMapping("/{sessionId}/report")
public ResponseEntity<InterviewReportResponse> getInterviewReport(
@PathVariable Long sessionId,
@RequestParam Long memberId) {

// 1. 소유권 검증: 내 면접이 맞는지 확인 (IDOR 취약점 방어)
InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The getInterviewReport endpoint (and others using validateAndGetSessionOwnership) is vulnerable to Insecure Direct Object Reference (IDOR). The memberId used for ownership verification is taken directly from the request parameters (@RequestParam Long memberId) without being verified against the actual authenticated user. An attacker can access any user's interview report by providing that user's memberId and the corresponding sessionId. As noted in the code comments, this should be replaced with a secure authentication mechanism (e.g., @AuthenticationPrincipal from Spring Security) to ensure the memberId corresponds to the logged-in user.

References
  1. When accessing resources via an identifier (e.g., sessionId), always verify that the resource belongs to the currently authenticated user to prevent unauthorized access.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정했습니다.


try {
// 2. 리포트 서비스 호출 (이 안에서 DONE 상태인지 검증)
InterviewReportResponse report = interviewReportService.getReport(session);
return ResponseEntity.ok(report);
} catch (IllegalStateException e) {
// 상태가 DONE이 아닐 경우 403 Forbidden 에러 반환
throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage());
}
Comment on lines +117 to +119
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

IllegalStateException이 발생했을 때 HttpStatus.FORBIDDEN (403)을 반환하도록 구현하셨습니다. 하지만 403은 인증된 사용자가 리소스에 접근할 권한이 없을 때 사용하는 상태 코드입니다. 이 경우, 사용자는 자신의 면접 리포트에 접근할 권한이 있지만, 면접이 아직 완료되지 않아 리소스의 상태가 요청을 처리하기에 적절하지 않은 상황입니다.
이러한 "상태 충돌" 상황에는 HttpStatus.CONFLICT (409)가 더 적합한 상태 코드입니다. API의 의미를 더 명확하게 전달하기 위해 상태 코드를 변경하는 것을 고려해 보세요.

Suggested change
} catch (IllegalStateException e) {
// 상태가 DONE이 아닐 경우 403 Forbidden 에러 반환
throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage());
}
} catch (IllegalStateException e) {
// 상태가 DONE이 아닐 경우 409 Conflict 에러 반환
throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());
}

}

//보안 리뷰 반영: IDOR 취약점 방지 및 중복 로직 제거를 위한 전용 검증 메서드 현재는 프론트엔드 연동을 위해 @RequestParam 으로 넘겨받은 memberId를 검증에 사용 중 / 추후 Spring Security 적용 시, 파라미터를 삭제하고 @AuthenticationPrincipal 또는 SecurityContext에서 추출한 실제 로그인 사용자의 ID로 authenticatedMemberId를 주입받도록 수정
private InterviewSession validateAndGetSessionOwnership(Long sessionId, Long authenticatedMemberId) {
InterviewSession session = interviewRepository.findById(sessionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "면접 세션을 찾을 수 없습니다."));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.aibe.team2.domain.interview.dto;

import com.aibe.team2.domain.interview.entity.InterviewSession;
import com.aibe.team2.domain.interview.enums.InterviewMode;
import com.aibe.team2.domain.interview.enums.InterviewSessionStatus;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Builder
public class InterviewReportResponse {
private Long sessionId;
private InterviewMode interviewMode;
private String interviewType;
private InterviewSessionStatus status;
private Integer finalScore;
private LocalDateTime createdAt;

// 연관된 이력서와 공고의 '제목'을 프론트엔드에 전달하기 위한 필드
private String resumeTitle;
private String jobTitle;

public static InterviewReportResponse of(InterviewSession session, String resumeTitle, String jobTitle) {
return InterviewReportResponse.builder()
.sessionId(session.getId())
.interviewMode(session.getInterviewMode())
.interviewType(session.getInterviewType())
.status(session.getStatus())
.finalScore(session.getFinalScore())
.createdAt(session.getCreatedAt())
.resumeTitle(resumeTitle)
.jobTitle(jobTitle)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public class InterviewRequestDto {
private String message;
private String modelVariant;
private InterviewMode interviewMode;

// [FR-INT-06, 07] 프롬프트에 동적 주입할 컨텍스트 데이터 필드 추가
private String resumeContent;
private String jobDescription;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,55 @@

import com.aibe.team2.domain.interview.dto.InterviewRequestDto;
import com.aibe.team2.domain.interview.dto.VoiceSessionResponse;
import com.aibe.team2.domain.interview.entity.InterviewSession;
import com.aibe.team2.domain.interview.enums.InterviewMode;
import com.aibe.team2.domain.interview.enums.InterviewSessionStatus;
import com.aibe.team2.domain.jobposting.entity.JobPosting;
import com.aibe.team2.domain.jobposting.repository.JobPostingRepository;
import com.aibe.team2.domain.resume.entity.Resume;
import com.aibe.team2.domain.resume.repository.ResumeRepository;
import com.aibe.team2.global.redis.lock.DistributedLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;

@Slf4j
@Service
@RequiredArgsConstructor
public class ConversationManager {

private final GeminiService geminiService;
private final RetellService retellService;
private final InterviewManager interviewManager; // 추가: 상태 변경을 위한 Manager 주입
private final InterviewManager interviewManager; // 상태 변경을 위한 Manager 주입

// @DistributedLock(key = "text-streaming", waitTime = 1, leaseTime = 20) <- 추후 분산 락 구현시 주석 해제
public void startTextStreaming(Long sessionId, String answer, String modelVariant, InterviewMode interviewMode, SseEmitter emitter) {
InterviewRequestDto request = new InterviewRequestDto(answer, modelVariant, interviewMode);
private final ResumeRepository resumeRepository; // [FR-INT-06] 이력서 조회를 위한 의존성 추가
private final JobPostingRepository jobPostingRepository; // [FR-INT-07] 채용 공고 조회를 위한 의존성 추가

geminiService.streamQuestion(String.valueOf(sessionId), request).subscribe(
@DistributedLock(key = "text-streaming", waitTime = 1, leaseTime = 20)
public void startTextStreaming(InterviewSession session, String answer, String modelVariant, InterviewMode interviewMode, SseEmitter emitter) {

// [FR-INT-06] 자기소개서 내용 안전하게 조회 및 추출
String resumeContent = null;
if (session.getResumeId() != null) {
resumeContent = resumeRepository.findByIdAndMemberId(session.getResumeId(), session.getMemberId())
.map(Resume::getContent)
.orElse(null); // 권한이 없거나 찾을 수 없으면 주입하지 않음
}

// [FR-INT-07] 채용 공고 내용 안전하게 조회 및 추출
String jobDescription = null;
if (session.getJobPostingId() != null) {
jobDescription = jobPostingRepository.findByIdAndMemberId(session.getJobPostingId(), session.getMemberId())
.map(JobPosting::getJobDescription)
.orElse(null);
}

// DTO 생성 시 추출한 이력서 및 공고 데이터 모두 포함
InterviewRequestDto request = new InterviewRequestDto(answer, modelVariant, interviewMode, resumeContent, jobDescription);

geminiService.streamQuestion(String.valueOf(session.getId()), request).subscribe(
data -> {
try {
emitter.send(SseEmitter.event().name("message").data(data));
Expand All @@ -30,8 +59,9 @@ public void startTextStreaming(Long sessionId, String answer, String modelVarian
}
},
error -> {
// 추가: 스트리밍 중 에러 발생 시 세션을 ABORTED 상태로 전환
interviewManager.advanceStatus(sessionId, InterviewSessionStatus.ABORTED);
log.error("스트리밍 중 에러 발생. 세션을 ABORTED 상태로 전환합니다. SessionID: {}", session.getId(), error);
// 통계 제외를 위해 에러 발생 시 상태를 ABORTED로 변경
interviewManager.advanceStatus(session.getId(), InterviewSessionStatus.ABORTED);
emitter.completeWithError(error);
},
emitter::complete
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.aibe.team2.domain.interview.service;

import com.aibe.team2.domain.interview.dto.InterviewRequestDto;
import com.aibe.team2.domain.interview.enums.InterviewMode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
Expand All @@ -13,6 +14,7 @@
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -43,13 +45,26 @@ public Flux<String> streamQuestion(String sessionId, InterviewRequestDto request

String fullUrl = String.format("%s/v1beta/models/%s:streamGenerateContent?alt=sse", rootUrl, model);

// interviewMode.name()을 파일명으로 사용하여 분위기 프롬프트 로드 (NORMAL, FOLLOW_UP, STRESS)
String atmospherePrompt = loadPromptFile(request.getInterviewMode().name());
String constraints = loadPromptFile("constraints");
String finalPrompt = String.format("%s\n\n%s\n\n[Candidate Answer]\n%s",
atmospherePrompt, constraints, request.getMessage());

log.info("[Gemini-Success] Session: {}, Mode: {}", sessionId, request.getInterviewMode());
// [FR-INT-06] 이력서 기반 꼬리 질문 생성을 위한 컨텍스트 동적 주입
String resumeContext = "";
if (request.getResumeContent() != null && !request.getResumeContent().isBlank()) {
resumeContext = "\n\n[Candidate's Resume]\n다음은 지원자의 자기소개서 내용입니다. 이를 바탕으로 지원자의 경험을 묻는 꼬리 질문을 생성하세요.\n" + request.getResumeContent();
}

// [FR-INT-07] 채용 공고 기반 맞춤형 질문 생성을 위한 컨텍스트 동적 주입
String jobContext = "";
if (request.getJobDescription() != null && !request.getJobDescription().isBlank()) {
jobContext = "\n\n[Job Posting Requirements]\n다음은 지원자가 지원한 채용 공고의 상세 내용(요구 역량 및 주요 업무)입니다. 이를 바탕으로 직무 적합성을 검증하는 질문을 생성하세요.\n" + request.getJobDescription();
}
Comment on lines +51 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이력서와 채용 공고 컨텍스트를 프롬프트에 주입하기 위한 문자열이 코드에 직접 하드코딩되어 있습니다. 프롬프트는 비즈니스 로직의 중요한 부분이므로, 다른 프롬프트 파일(NORMAL.txt, constraints.txt)처럼 외부 .txt 파일로 분리하여 관리하는 것이 유지보수성 측면에서 더 좋습니다.
예를 들어, resume_context.txtjob_context.txt 파일을 만들어 프롬프트 템플릿을 저장하고, String.format을 사용해 동적으로 내용을 채워넣는 방식을 고려해볼 수 있습니다.


// 최종 프롬프트 조립 (분위기 + 이력서 + 채용공고 + 제약조건 + 사용자 답변)
String finalPrompt = String.format("%s%s%s\n\n%s\n\n[Candidate Answer]\n%s",
atmospherePrompt, resumeContext, jobContext, constraints, request.getMessage());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The construction of finalPrompt is vulnerable to Prompt Injection. User-supplied input from request.getMessage() is directly concatenated into the prompt string. An attacker could craft a malicious response to manipulate the LLM's instructions, bypass constraints, or potentially leak the system prompt. It is recommended to use structured prompting techniques or sanitize user input to prevent the LLM from interpreting user data as instructions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정했습니다


log.info("[Gemini-Streaming] Session: {}, Mode: {}", sessionId, request.getInterviewMode());

return webClient.post()
.uri(URI.create(fullUrl))
Expand All @@ -64,15 +79,31 @@ public Flux<String> streamQuestion(String sessionId, InterviewRequestDto request
.doOnError(e -> log.error("=== 🚨 Gemini API 호출 에러: {} ===", e.getMessage()));
}

//보안 리뷰 반영 (Remediation): Path Traversal 방어를 위한 허용 목록(Allow-list) 검증
private String loadPromptFile(String fileName) {
final String currentFileName = fileName;

// 허용 목록 검증: Enum 상수에 있거나 'constraints'인 경우만 허용
boolean isAllowed = Arrays.stream(InterviewMode.values())
.anyMatch(mode -> mode.name().equals(currentFileName)) || "constraints".equals(currentFileName);

String targetFileName = fileName;
if (!isAllowed) {
log.error("보안 위협: 허용되지 않은 파일 이름 접근 시도 - {}", fileName);
targetFileName = "NORMAL";
}

try {
Resource resource = resourceLoader.getResource("classpath:prompts/" + fileName + ".txt");
if (!resource.exists()) resource = resourceLoader.getResource("classpath:prompts/NORMAL.txt");
Resource resource = resourceLoader.getResource("classpath:prompts/" + targetFileName + ".txt");
if (!resource.exists()) {
log.warn("프롬프트 파일이 존재하지 않아 기본 설정을 로드합니다: {}", targetFileName);
resource = resourceLoader.getResource("classpath:prompts/NORMAL.txt");
}
return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
} catch (IOException e) {
// 리뷰 반영: 예외 발생로그를 남겨 근본 원인 파악이 가능하도록 수정
log.error("Failed to load prompt file: {}", fileName, e);
return "전문 면접관으로서 지원자에게 질문을 던져주세요.";
// 리뷰 반영: 에러 로깅실제 원인(e) 기록
log.error("❌ 프롬프트 파일 로드 실패 [파일명: {}]: ", targetFileName, e);
return "면접관으로서 질문을 생성하세요.";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.aibe.team2.domain.interview.service;

import com.aibe.team2.domain.interview.dto.InterviewReportResponse;
import com.aibe.team2.domain.interview.entity.InterviewSession;
import com.aibe.team2.domain.interview.enums.InterviewSessionStatus;
import com.aibe.team2.domain.jobposting.entity.JobPosting;
import com.aibe.team2.domain.jobposting.repository.JobPostingRepository;
import com.aibe.team2.domain.resume.entity.Resume;
import com.aibe.team2.domain.resume.repository.ResumeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InterviewReportService {

private final ResumeRepository resumeRepository;
private final JobPostingRepository jobPostingRepository;

public InterviewReportResponse getReport(InterviewSession session) {

// 🚀 [FR-INT-08] 핵심: DONE 상태 세션만 리포트 조회가 가능해야 한다.
if (session.getStatus() != InterviewSessionStatus.DONE) {
throw new IllegalStateException("면접이 정상적으로 완료된(DONE) 세션만 리포트를 조회할 수 있습니다. 현재 상태: " + session.getStatus());
}

// 이력서 제목 조회
String resumeTitle = null;
if (session.getResumeId() != null) {
resumeTitle = resumeRepository.findById(session.getResumeId())
.map(Resume::getTitle)
.orElse("삭제된 이력서");
}

// 채용 공고 제목 조회
String jobTitle = null;
if (session.getJobPostingId() != null) {
jobTitle = jobPostingRepository.findById(session.getJobPostingId())
.map(JobPosting::getJobTitle)
.orElse("삭제된 공고");
}
Comment on lines +31 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

리포트 생성 시 이력서와 채용 공고 제목을 조회할 때 findById()를 사용하고 있습니다. 이 경우, 면접 세션(session)의 소유권은 검증되었지만, 해당 세션에 연결된 이력서(resumeId)나 채용 공고(jobPostingId)가 정말 해당 사용자의 소유인지 검증하지 않아 IDOR(Insecure Direct Object Reference) 취약점이 발생할 수 있습니다.
ConversationManager에서 구현된 것처럼, memberId를 함께 사용하여 resumeRepository.findByIdAndMemberId()jobPostingRepository.findByIdAndMemberId()를 호출하여 소유권을 반드시 검증해야 합니다.

Suggested change
if (session.getResumeId() != null) {
resumeTitle = resumeRepository.findById(session.getResumeId())
.map(Resume::getTitle)
.orElse("삭제된 이력서");
}
// 채용 공고 제목 조회
String jobTitle = null;
if (session.getJobPostingId() != null) {
jobTitle = jobPostingRepository.findById(session.getJobPostingId())
.map(JobPosting::getJobTitle)
.orElse("삭제된 공고");
}
if (session.getResumeId() != null) {
resumeTitle = resumeRepository.findByIdAndMemberId(session.getResumeId(), session.getMemberId())
.map(Resume::getTitle)
.orElse("삭제된 이력서");
}
// 채용 공고 제목 조회
String jobTitle = null;
if (session.getJobPostingId() != null) {
jobTitle = jobPostingRepository.findByIdAndMemberId(session.getJobPostingId(), session.getMemberId())
.map(JobPosting::getJobTitle)
.orElse("삭제된 공고");
}
References
  1. When accessing resources via an identifier (e.g., sessionId), always verify that the resource belongs to the currently authenticated user to prevent unauthorized access.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정했습니다


return InterviewReportResponse.of(session, resumeTitle, jobTitle);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
import com.aibe.team2.domain.jobposting.entity.JobPosting;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;

public interface JobPostingRepository extends JpaRepository<JobPosting, Long> {

// 특정 유저의 공고를 등록일 기준 최신순으로 조회
List<JobPosting> findAllByMemberIdOrderByCreatedAtDesc(Long memberId);

// [FR-INT-07] IDOR 방어: Id와 MemberId를 함께 검증하여 공고 조회 / 작성자 : 최원준
Optional<JobPosting> findByIdAndMemberId(Long id, Long memberId);
}
Loading