feat: 회원가입 개인정보 수집·이용 동의 추가 (고지 후 동의 + 시각 증빙)
- 가입 화면: 수집 항목·목적·보유기간·거부권 안내문 + 필수 동의 체크박스 - SignupRequest: privacyConsent @NotNull @AssertTrue (서버가 최종 방어선) - AuthService: 서비스 계층 이중 확인(defense in depth), 동의 시각은 서버 시계로 기록 - StudentProfile: privacy_consent_at 컬럼 추가 — boolean이 아닌 timestamp로 증빙 - 경리 CSV: '개인정보동의일' 열 추가 (감사 대비) - 테스트 2개 추가: 동의 없으면(false/null) 400, 정상 가입 시 동의 시각 기록 → 전체 14개 테스트 통과 운영 검증: 동의 false/누락 → 400, 동의 → 201 + DB privacy_consent_at 기록 확인 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8332f306f7
commit
cb0fb54185
@ -2,6 +2,7 @@ package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
@ -72,12 +73,22 @@ public class StudentProfile {
|
||||
@Column(nullable = false, length = 20)
|
||||
private String emergencyPhone;
|
||||
|
||||
/**
|
||||
* 개인정보 수집·이용에 동의한 시각.
|
||||
* 학습 포인트: 동의는 "체크박스를 확인했다"로 끝나지 않는다. 개인정보보호법상
|
||||
* 회사는 "언제 동의받았는지"를 증명할 수 있어야 한다. 그래서 체크 여부(boolean)가
|
||||
* 아니라 동의 시각(timestamp)을 저장한다 — 시각이 있으면 동의했다는 사실도 담긴다.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Instant privacyConsentAt;
|
||||
|
||||
protected StudentProfile() {
|
||||
}
|
||||
|
||||
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
|
||||
String address, String school, String major,
|
||||
String emergencyName, String emergencyRelation, String emergencyPhone) {
|
||||
String emergencyName, String emergencyRelation, String emergencyPhone,
|
||||
Instant privacyConsentAt) {
|
||||
this.user = user;
|
||||
this.companyEmailLocal = companyEmailLocal;
|
||||
this.birthDate = birthDate;
|
||||
@ -88,6 +99,7 @@ public class StudentProfile {
|
||||
this.emergencyName = emergencyName;
|
||||
this.emergencyRelation = emergencyRelation;
|
||||
this.emergencyPhone = emergencyPhone;
|
||||
this.privacyConsentAt = privacyConsentAt;
|
||||
}
|
||||
|
||||
/** 회사 도메인은 여기 한 곳에서만 정의한다. 바뀌면 이 상수만 고친다. */
|
||||
@ -141,4 +153,8 @@ public class StudentProfile {
|
||||
public String getEmergencyPhone() {
|
||||
return emergencyPhone;
|
||||
}
|
||||
|
||||
public Instant getPrivacyConsentAt() {
|
||||
return privacyConsentAt;
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,6 +66,14 @@ public class AuthService {
|
||||
*/
|
||||
@Transactional
|
||||
public User signup(SignupRequest req) {
|
||||
// 개인정보 동의는 DTO의 @AssertTrue가 먼저 거르지만, 서비스에서도 한 번 더 확인한다.
|
||||
// 학습 포인트: 법적 요건처럼 "절대 뚫리면 안 되는 규칙"은 겹으로 지킨다(defense in depth).
|
||||
// 나중에 누가 다른 경로(배치, 관리자 API 등)로 이 메서드를 불러도 규칙이 지켜진다.
|
||||
if (!Boolean.TRUE.equals(req.privacyConsent())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "개인정보 수집·이용에 동의해야 가입할 수 있습니다.");
|
||||
}
|
||||
|
||||
// 학습 포인트: 중복 검사와 저장 사이에 다른 요청이 끼어들 수도 있지만(경쟁 상태),
|
||||
// unique 제약이 걸려 있어 최악의 경우에도 DB가 중복을 막아 준다.
|
||||
// 이 사전 검사는 사용자에게 친절한 에러 메시지를 주기 위한 것이다.
|
||||
@ -88,10 +96,12 @@ public class AuthService {
|
||||
User user = userRepository.save(
|
||||
new User(req.username(), passwordHash, req.name(), "STUDENT", req.track()));
|
||||
|
||||
// 동의 "시각"은 클라이언트가 아니라 서버 시계로 기록한다 — 증빙은 조작 불가능해야 하니까.
|
||||
studentProfileRepository.save(new StudentProfile(
|
||||
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
|
||||
req.school(), req.major(),
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone()));
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
|
||||
java.time.Instant.now()));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ public class StudentProfileService {
|
||||
StringBuilder sb = new StringBuilder(""); // BOM
|
||||
|
||||
// 헤더
|
||||
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,가입일\n");
|
||||
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
|
||||
|
||||
for (StudentProfile p : allProfiles()) {
|
||||
sb.append(csv(p.getUser().getName())).append(',')
|
||||
@ -62,6 +62,9 @@ public class StudentProfileService {
|
||||
.append(csv(p.getEmergencyName())).append(',')
|
||||
.append(csv(p.getEmergencyRelation())).append(',')
|
||||
.append(csv(p.getEmergencyPhone())).append(',')
|
||||
// 개인정보 동의일 — 감사·증빙 대비. (동의 없이 가입된 행은 존재할 수 없다)
|
||||
.append(csv(df.format(p.getPrivacyConsentAt().atZone(
|
||||
java.time.ZoneId.of("Asia/Seoul"))))).append(',')
|
||||
.append(csv(df.format(p.getUser().getCreatedAt().atZone(
|
||||
java.time.ZoneId.of("Asia/Seoul")))))
|
||||
.append('\n');
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Past;
|
||||
@ -86,6 +87,16 @@ public record SignupRequest(
|
||||
|
||||
@NotBlank(message = "비상 연락처 전화번호는 필수입니다.")
|
||||
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "비상 연락처 전화번호 형식이 올바르지 않아요.")
|
||||
String emergencyPhone
|
||||
String emergencyPhone,
|
||||
|
||||
/*
|
||||
* 개인정보 수집·이용 동의.
|
||||
* 학습 포인트: @AssertTrue는 "반드시 true여야 통과"다. 동의 없이 개인정보를
|
||||
* 수집하면 개인정보보호법 위반이므로, 화면 체크박스만 믿지 않고 서버가 다시 확인한다.
|
||||
* (Boolean이라 null도 올 수 있는데, @NotNull이 그 구멍을 막는다.)
|
||||
*/
|
||||
@NotNull(message = "개인정보 수집·이용 동의가 필요합니다.")
|
||||
@AssertTrue(message = "개인정보 수집·이용에 동의해야 가입할 수 있습니다.")
|
||||
Boolean privacyConsent
|
||||
) {
|
||||
}
|
||||
|
||||
@ -113,6 +113,36 @@ class AuthServiceTest {
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("회원가입: 개인정보 동의 없이(false/null) 가입하려 하면 400을 던진다")
|
||||
void signup_rejects_without_privacy_consent() {
|
||||
// 화면 체크박스를 우회해 API로 직접 요청하는 경우를 흉내 낸다
|
||||
ResponseStatusException exFalse = assertThrows(ResponseStatusException.class,
|
||||
() -> authService.signup(sampleSignupWithConsent("honggildong", "hong", false)));
|
||||
ResponseStatusException exNull = assertThrows(ResponseStatusException.class,
|
||||
() -> authService.signup(sampleSignupWithConsent("honggildong", "hong", null)));
|
||||
|
||||
assertEquals(400, exFalse.getStatusCode().value());
|
||||
assertEquals(400, exNull.getStatusCode().value());
|
||||
verify(userRepository, never()).save(any());
|
||||
verify(studentProfileRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("회원가입: 정상 가입되면 동의 시각이 프로필에 기록된다")
|
||||
void signup_records_consent_timestamp() {
|
||||
when(userRepository.existsByUsername("honggildong")).thenReturn(false);
|
||||
when(studentProfileRepository.existsByCompanyEmailLocal("hong")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
authService.signup(sampleSignup("honggildong", "hong"));
|
||||
|
||||
// 저장된 프로필을 붙잡아(captor) 동의 시각이 실제로 들어갔는지 확인한다
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(dev.awesomedev.mirim.domain.StudentProfile.class);
|
||||
verify(studentProfileRepository).save(captor.capture());
|
||||
assertNotNull(captor.getValue().getPrivacyConsentAt());
|
||||
}
|
||||
|
||||
// ─────────────────────── 멘토 재설정 권한 ───────────────────────
|
||||
|
||||
@Test
|
||||
@ -138,12 +168,17 @@ class AuthServiceTest {
|
||||
assertTrue(passwordEncoder.matches("newpass456", student.getPasswordHash()));
|
||||
}
|
||||
|
||||
/** 회원가입 요청 본문 샘플 — 테스트마다 다시 쓰지 않게 한 곳에 모은다. */
|
||||
/** 회원가입 요청 본문 샘플 — 테스트마다 다시 쓰지 않게 한 곳에 모은다. (동의 완료 상태) */
|
||||
private dev.awesomedev.mirim.web.dto.SignupRequest sampleSignup(String username, String emailLocal) {
|
||||
return sampleSignupWithConsent(username, emailLocal, true);
|
||||
}
|
||||
|
||||
private dev.awesomedev.mirim.web.dto.SignupRequest sampleSignupWithConsent(
|
||||
String username, String emailLocal, Boolean consent) {
|
||||
return new dev.awesomedev.mirim.web.dto.SignupRequest(
|
||||
username, "password12", "홍길동", "DEV",
|
||||
emailLocal, java.time.LocalDate.of(2007, 3, 15), "010-1234-5678",
|
||||
"서울", "미림고", "소프트웨어과",
|
||||
"홍판서", "부", "010-9999-8888");
|
||||
"홍판서", "부", "010-9999-8888", consent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ const EMPTY = {
|
||||
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
|
||||
companyEmailLocal: '', birthDate: '', phone: '', address: '', school: '', major: '',
|
||||
emergencyName: '', emergencyRelation: '', emergencyPhone: '',
|
||||
privacyConsent: false, // 개인정보 수집·이용 동의 — 체크해야만 가입 가능
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
@ -57,6 +58,7 @@ export default function SignupPage() {
|
||||
if (!form.emergencyRelation.trim()) return '비상 연락처 관계를 입력해 주세요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.emergencyPhone))
|
||||
return '비상 연락처 전화번호 형식을 확인해 주세요.';
|
||||
if (!form.privacyConsent) return '개인정보 수집·이용에 동의해야 가입할 수 있어요.';
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -84,6 +86,7 @@ export default function SignupPage() {
|
||||
emergencyName: form.emergencyName.trim(),
|
||||
emergencyRelation: form.emergencyRelation.trim(),
|
||||
emergencyPhone: form.emergencyPhone.trim(),
|
||||
privacyConsent: form.privacyConsent, // 서버도 @AssertTrue로 다시 확인한다
|
||||
});
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
@ -195,10 +198,30 @@ export default function SignupPage() {
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" />
|
||||
|
||||
<div className="tip" style={{ marginTop: 4 }}>
|
||||
여기서 받는 정보는 회사 인사·비상 연락용이에요. 주민등록번호·계좌번호 같은
|
||||
민감정보는 <strong>여기 넣지 않아요</strong> — 근로계약서 작성 때 따로 받습니다.
|
||||
{/* ── 개인정보 수집·이용 동의 ──
|
||||
학습 포인트: 동의를 받으려면 "무엇을, 왜, 얼마나" 수집하는지 먼저 알려야 한다
|
||||
(고지 후 동의). 안내문 없이 체크박스만 두면 법적으로 유효한 동의가 아니다. */}
|
||||
<div className="section-title">개인정보 수집·이용 동의</div>
|
||||
<div className="tip" style={{ marginTop: 0, fontSize: 13, lineHeight: 1.7 }}>
|
||||
<strong>수집 항목</strong> — 이름, 생년월일, 연락처, 주소, 학교·전공, 비상 연락처<br />
|
||||
<strong>수집 목적</strong> — 수습 기간 인사 관리, 급여·4대보험 등 행정 처리, 비상시 연락<br />
|
||||
<strong>보유 기간</strong> — 재직 기간 동안 보관하고, 퇴직 시 관계 법령이 정한 기간이
|
||||
지나면 지체 없이 파기합니다.<br />
|
||||
<strong>동의 거부 안내</strong> — 동의하지 않을 권리가 있으나, 위 정보는 근로 관계에
|
||||
꼭 필요한 최소한의 정보라 동의하지 않으면 가입(채용 절차 진행)이 어렵습니다.<br />
|
||||
주민등록번호·계좌번호 같은 민감정보는 <strong>여기서 받지 않아요</strong> —
|
||||
근로계약서 작성 때 따로 받습니다.
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10,
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: 600 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.privacyConsent}
|
||||
onChange={(e) => set('privacyConsent', e.target.checked)}
|
||||
style={{ width: 16, height: 16, accentColor: 'var(--primary, #4f46e5)' }}
|
||||
/>
|
||||
위 개인정보 수집·이용에 동의합니다. (필수)
|
||||
</label>
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={submitting}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user