Skip to content

Commit c81eb78

Browse files
authored
feat : 코딩 테스트 코드 자동 저장(Draft) 기능 및 동시성 제어 구현 (#204)
* feat: 코드 자동 저장(Draft) 기능 추가 및 낙관적 락 적용 - 도메인 분리: Submission과 별도로 Draft 도메인 패키지 신설 - DB 설계: User+Problem+Language 복합 유니크 키 및 Version 컬럼 추가 - 동시성 제어: - JPA Optimistic Locking(@Version) 적용 - 요청 버전과 DB 버전을 비교하는 명시적 검증 로직 구현 - 충돌 시 409 Conflict 예외 처리 - API 구현: - POST /api/drafts (자동 저장/업데이트) - GET /api/drafts (임시 저장 내역 조회) * fix: 자동 저장 데이터 조회 시 예외 처리 추가 - 기존에 저장된 값이 없다면 null 값을 리턴하도록 함 * chore: 컨트롤러 swagger 어노테이션 추가 * fix: 패키지 이름 오타 수정 * fix: NPE 발생할 수 있는 조건문 수정
1 parent 0d632c6 commit c81eb78

File tree

11 files changed

+431
-0
lines changed

11 files changed

+431
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.ezcode.codetest.application.draft.dto.request;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import jakarta.validation.constraints.Size;
5+
6+
public record DraftSaveRequest(
7+
@NotNull(message = "문제 ID는 필수입니다.")
8+
Long problemId,
9+
10+
@NotNull(message = "언어 ID는 필수입니다.")
11+
Long languageId,
12+
13+
@NotNull(message = "코드는 필수입니다.")
14+
@Size(max = 100000, message = "코드는 최대 100,000자까지 입력 가능합니다.")
15+
String code,
16+
17+
Long version // 새 엔티티는 null일 수 있으므로 @NotNull 제외
18+
) {
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.ezcode.codetest.application.draft.dto.response;
2+
3+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
4+
5+
import lombok.Builder;
6+
7+
@Builder
8+
public record DraftResponse(
9+
Long problemId,
10+
Long languageId,
11+
String code,
12+
Long version
13+
) {
14+
public static DraftResponse from(Draft draft) {
15+
return DraftResponse.builder()
16+
.problemId(draft.getProblem().getId())
17+
.languageId(draft.getLanguage().getId())
18+
.code(draft.getCode())
19+
.version(draft.getVersion())
20+
.build();
21+
}
22+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package org.ezcode.codetest.application.draft.service;
2+
3+
import java.util.Optional;
4+
5+
import org.ezcode.codetest.application.draft.dto.request.DraftSaveRequest;
6+
import org.ezcode.codetest.application.draft.dto.response.DraftResponse;
7+
import org.ezcode.codetest.domain.draft.exception.DraftException;
8+
import org.ezcode.codetest.domain.draft.exception.DraftExceptionCode;
9+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
10+
import org.ezcode.codetest.domain.draft.service.DraftDomainService;
11+
import org.ezcode.codetest.domain.language.model.entity.Language;
12+
import org.ezcode.codetest.domain.language.service.LanguageDomainService;
13+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
14+
import org.ezcode.codetest.domain.problem.service.ProblemDomainService;
15+
import org.ezcode.codetest.domain.user.model.entity.User;
16+
import org.ezcode.codetest.domain.user.service.UserDomainService;
17+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
18+
import org.springframework.stereotype.Service;
19+
import org.springframework.transaction.annotation.Transactional;
20+
21+
import lombok.RequiredArgsConstructor;
22+
import lombok.extern.slf4j.Slf4j;
23+
24+
@Slf4j
25+
@Service
26+
@RequiredArgsConstructor
27+
public class DraftService {
28+
29+
private final DraftDomainService draftDomainService;
30+
31+
private final UserDomainService userDomainService;
32+
private final ProblemDomainService problemDomainService;
33+
private final LanguageDomainService languageDomainService;
34+
35+
@Transactional
36+
public DraftResponse autoSave(Long userId, DraftSaveRequest request) {
37+
38+
User user = userDomainService.getUserById(userId);
39+
Problem problem = problemDomainService.getProblem(request.problemId());
40+
Language language = languageDomainService.getLanguage(request.languageId());
41+
42+
Draft draft;
43+
try {
44+
draft = draftDomainService.autoSave(
45+
user,
46+
problem,
47+
language,
48+
request.code(),
49+
request.version()
50+
);
51+
} catch (ObjectOptimisticLockingFailureException e) {
52+
throw new DraftException(DraftExceptionCode.DRAFT_VERSION_CONFLICT);
53+
}
54+
55+
return DraftResponse.from(draft);
56+
}
57+
58+
@Transactional(readOnly = true)
59+
public Optional<DraftResponse> getDraft(Long userId, Long problemId, Long languageId) {
60+
61+
User user = userDomainService.getUserById(userId);
62+
Problem problem = problemDomainService.getProblem(problemId);
63+
Language language = languageDomainService.getLanguage(languageId);
64+
65+
return draftDomainService.getDraft(user, problem, language)
66+
.map(DraftResponse::from);
67+
}
68+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.ezcode.codetest.domain.draft.exception;
2+
3+
import org.ezcode.codetest.common.base.exception.BaseException;
4+
import org.ezcode.codetest.common.base.exception.ResponseCode;
5+
import org.springframework.http.HttpStatus;
6+
7+
import lombok.Getter;
8+
9+
@Getter
10+
public class DraftException extends BaseException {
11+
12+
private final ResponseCode responseCode;
13+
private final HttpStatus httpStatus;
14+
private final String message;
15+
16+
public DraftException(ResponseCode responseCode) {
17+
this.responseCode = responseCode;
18+
this.httpStatus = responseCode.getStatus();
19+
this.message = responseCode.getMessage();
20+
}
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.ezcode.codetest.domain.draft.exception;
2+
3+
import org.ezcode.codetest.common.base.exception.ResponseCode;
4+
import org.springframework.http.HttpStatus;
5+
6+
import lombok.Getter;
7+
import lombok.RequiredArgsConstructor;
8+
9+
@Getter
10+
@RequiredArgsConstructor
11+
public enum DraftExceptionCode implements ResponseCode {
12+
13+
DRAFT_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 임시 저장 내역이 존재하지 않습니다."),
14+
15+
// 409 Conflict: 낙관적 락 충돌 발생 시 사용
16+
DRAFT_VERSION_CONFLICT(false, HttpStatus.CONFLICT, "다른 탭에서 저장된 최신 코드가 존재합니다. 새로고침이 필요합니다."),
17+
18+
// 본인의 Draft가 아닌데 조회/수정하려 할 때 (URL 조작 등)
19+
DRAFT_ACCESS_DENIED(false, HttpStatus.FORBIDDEN, "해당 임시 저장 내역에 접근할 권한이 없습니다."),
20+
;
21+
22+
private final boolean success;
23+
private final HttpStatus status;
24+
private final String message;
25+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.ezcode.codetest.domain.draft.model.entity;
2+
3+
import org.ezcode.codetest.domain.language.model.entity.Language;
4+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
5+
import org.ezcode.codetest.domain.user.model.entity.User;
6+
7+
import jakarta.persistence.Entity;
8+
import jakarta.persistence.FetchType;
9+
import jakarta.persistence.GeneratedValue;
10+
import jakarta.persistence.GenerationType;
11+
import jakarta.persistence.Id;
12+
import jakarta.persistence.JoinColumn;
13+
import jakarta.persistence.Lob;
14+
import jakarta.persistence.ManyToOne;
15+
import jakarta.persistence.Table;
16+
import jakarta.persistence.UniqueConstraint;
17+
import jakarta.persistence.Version;
18+
import lombok.AccessLevel;
19+
import lombok.Builder;
20+
import lombok.Getter;
21+
import lombok.NoArgsConstructor;
22+
23+
@Entity
24+
@Table(
25+
name = "draft",
26+
uniqueConstraints = {
27+
// 언어별 저장 지원
28+
@UniqueConstraint(
29+
name = "uk_draft_user_problem_language",
30+
columnNames = {"user_id", "problem_id", "language_id"}
31+
)
32+
}
33+
)
34+
@Getter
35+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
36+
public class Draft {
37+
38+
@Id
39+
@GeneratedValue(strategy = GenerationType.IDENTITY)
40+
private Long id;
41+
42+
@ManyToOne(fetch = FetchType.LAZY)
43+
@JoinColumn(name = "user_id", nullable = false)
44+
private User user;
45+
46+
@ManyToOne(fetch = FetchType.LAZY)
47+
@JoinColumn(name = "problem_id", nullable = false)
48+
private Problem problem;
49+
50+
@ManyToOne(fetch = FetchType.LAZY)
51+
@JoinColumn(name = "language_id", nullable = false)
52+
private Language language;
53+
54+
@Lob
55+
private String code;
56+
57+
@Version // 낙관적 락을 위한 버전 관리
58+
private Long version;
59+
60+
public void updateCode(String newCode) {
61+
this.code = newCode;
62+
}
63+
64+
@Builder
65+
public Draft(User user, Problem problem, Language language, String code, Long version) {
66+
this.user = user;
67+
this.problem = problem;
68+
this.language = language;
69+
this.code = code;
70+
this.version = version;
71+
}
72+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.ezcode.codetest.domain.draft.repository;
2+
3+
import java.util.Optional;
4+
5+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
6+
import org.ezcode.codetest.domain.language.model.entity.Language;
7+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
8+
import org.ezcode.codetest.domain.user.model.entity.User;
9+
10+
public interface DraftRepository {
11+
12+
Draft save(Draft draft);
13+
14+
Optional<Draft> findByUserAndProblemAndLanguage(User user, Problem problem, Language language);
15+
16+
Draft saveAndFlush(Draft draft);
17+
18+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.ezcode.codetest.domain.draft.service;
2+
3+
import java.util.Objects;
4+
import java.util.Optional;
5+
6+
import org.ezcode.codetest.domain.draft.exception.DraftException;
7+
import org.ezcode.codetest.domain.draft.exception.DraftExceptionCode;
8+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
9+
import org.ezcode.codetest.domain.draft.repository.DraftRepository;
10+
import org.ezcode.codetest.domain.language.model.entity.Language;
11+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
12+
import org.ezcode.codetest.domain.user.model.entity.User;
13+
import org.springframework.stereotype.Service;
14+
15+
import lombok.RequiredArgsConstructor;
16+
import lombok.extern.slf4j.Slf4j;
17+
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class DraftDomainService {
22+
23+
private final DraftRepository draftRepository;
24+
25+
public Draft autoSave(User user, Problem problem, Language language, String code, Long version) {
26+
27+
Optional<Draft> existingDraftOpt = draftRepository.findByUserAndProblemAndLanguage(user, problem, language);
28+
29+
if (existingDraftOpt.isPresent()) {
30+
Draft draft = existingDraftOpt.get();
31+
32+
// 명시적 버전 검증: 클라이언트가 보낸 version과 DB의 version이 일치해야 함
33+
if (version != null && !Objects.equals(version, draft.getVersion())) {
34+
throw new DraftException(DraftExceptionCode.DRAFT_VERSION_CONFLICT);
35+
}
36+
37+
draft.updateCode(code);
38+
draftRepository.saveAndFlush(draft);
39+
40+
return draft;
41+
} else {
42+
// 새 엔티티인 경우
43+
Draft draft = Draft.builder()
44+
.user(user)
45+
.problem(problem)
46+
.language(language)
47+
.code(code)
48+
.build();
49+
50+
return draftRepository.saveAndFlush(draft);
51+
}
52+
}
53+
54+
public Optional<Draft> getDraft(User user, Problem problem, Language language) {
55+
56+
return draftRepository.findByUserAndProblemAndLanguage(user, problem, language);
57+
}
58+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.ezcode.codetest.infrastructure.persistence.repository.draft;
2+
3+
import java.util.Optional;
4+
5+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
6+
import org.ezcode.codetest.domain.language.model.entity.Language;
7+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
8+
import org.ezcode.codetest.domain.user.model.entity.User;
9+
import org.springframework.data.jpa.repository.JpaRepository;
10+
11+
public interface DraftJpaRepository extends JpaRepository<Draft, Long> {
12+
13+
Optional<Draft> findByUserAndProblemAndLanguage(User user, Problem problem, Language language);
14+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.ezcode.codetest.infrastructure.persistence.repository.draft;
2+
3+
import java.util.Optional;
4+
5+
import org.ezcode.codetest.domain.draft.model.entity.Draft;
6+
import org.ezcode.codetest.domain.draft.repository.DraftRepository;
7+
import org.ezcode.codetest.domain.language.model.entity.Language;
8+
import org.ezcode.codetest.domain.problem.model.entity.Problem;
9+
import org.ezcode.codetest.domain.user.model.entity.User;
10+
import org.springframework.stereotype.Repository;
11+
12+
import lombok.RequiredArgsConstructor;
13+
14+
@Repository
15+
@RequiredArgsConstructor
16+
public class DraftRepositoryImpl implements DraftRepository {
17+
18+
private final DraftJpaRepository draftJpaRepository;
19+
20+
@Override
21+
public Draft save(Draft draft) {
22+
23+
return draftJpaRepository.save(draft);
24+
}
25+
26+
@Override
27+
public Optional<Draft> findByUserAndProblemAndLanguage(User user, Problem problem, Language language) {
28+
29+
return draftJpaRepository.findByUserAndProblemAndLanguage(user, problem, language);
30+
}
31+
32+
@Override
33+
public Draft saveAndFlush(Draft draft) {
34+
35+
return draftJpaRepository.saveAndFlush(draft);
36+
}
37+
}

0 commit comments

Comments
 (0)