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:
AWESOMEDEV 2026-07-17 09:30:22 +09:00
parent 8332f306f7
commit cb0fb54185
6 changed files with 107 additions and 9 deletions

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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');

View File

@ -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
) {
}

View File

@ -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);
}
}

View File

@ -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}