Trip-Linker 개발일지 2 - 계정 보안 이력 관리 Entity·Repository 구현

2026. 6. 4. 19:32프로젝트/Trip-Linker 팀 프로젝트

반응형

계정 보안 이력 관리 Entity·Repository 구현

📋 한줄 요약

로그인 실패 횟수 누적·계정 잠금·비밀번호 변경 이력을 DB에 기록하기 위한 Entity와 Repository를 구현했다.


🎯 왜 만들었나

계정 보안 기능(5회 실패 시 5분 잠금, 90일마다 비밀번호 변경 권장)을 구현하려면

상태값을 User 엔티티에 저장하고, 보안 이벤트 발생 시 이력을 별도 테이블에 남겨야 했다.


📁 작업 순서 (파일별)

1. SecurityEventType.java

📦 domain/user/entity

추가한 코드

public enum SecurityEventType {
    PW_CHANGE,          // 비밀번호 변경 완료
    PW_CHANGE_NOTIFIED  // 90일 경과 변경 권장 모달 노출
}

왜 만들었나

DB의 event_type 컬럼에 문자열을 그대로 넣으면 오타·조건 분기가 위험하다.

Enum으로 타입을 고정해서 컴파일 타임에 오류를 잡을 수 있게 했다.


2. User.java

📦 domain/user/entity

추가한 컬럼

@Column(name = "login_fail_count", nullable = false)
private int loginFailCount = 0;      // 연속 로그인 실패 횟수

@Column(name = "locked_until")
private LocalDateTime lockedUntil;   // 잠금 해제 시각

@Column(name = "last_pw_changed_at")
private LocalDateTime lastPwChangedAt;  // 마지막 비밀번호 변경 시각

@Column(name = "pw_change_noti_at")
private LocalDateTime pwChangeNotiAt;   // 90일 변경 권장 모달 노출 시각

@Column(name = "deleted_at")
private LocalDateTime deletedAt;     // 탈퇴 처리 시각

// created_at / updated_at — @PrePersist / @PreUpdate 로 자동 세팅

추가한 메서드

// 잠금 상태 확인
public boolean isLocked() {
    return lockedUntil != null && LocalDateTime.now().isBefore(lockedUntil);
}

// 로그인 실패 시 호출 — 5회 도달 시 5분 잠금
public void increaseLoginFailCount() {
    this.loginFailCount++;
    if (this.loginFailCount >= 5) {
        this.lockedUntil = LocalDateTime.now().plusMinutes(5);
    }
}

// 로그인 성공 시 호출 — 실패 횟수 및 잠금 초기화
public void resetLoginFail() {
    this.loginFailCount = 0;
    this.lockedUntil = null;
}

// 비밀번호 변경 시 호출
public void updatePassword(String newPasswordHash) {
    this.passwordHash = newPasswordHash;
    this.lastPwChangedAt = LocalDateTime.now();
}

// 90일 권장 모달 노출 시각 기록
public void recordPwChangeNotified() {
    this.pwChangeNotiAt = LocalDateTime.now();
}

왜 이 구조로 만들었나

보안 상태 변경 로직(잠금, 초기화, 비밀번호 변경)을 Service가 아닌 Entity 메서드 안에 넣었다.

Service에서 필드를 직접 건드리지 않고 메서드를 호출하게 해서,

잠금 조건(5회)이 바뀌어도 User.java 한 곳만 수정하면 된다.

@PrePersist / @PreUpdate 를 쓴 이유

@PrePersist
protected void onCreate() {
    this.createdAt = LocalDateTime.now();
    this.updatedAt = LocalDateTime.now();
}

@PreUpdate
protected void onUpdate() {
    this.updatedAt = LocalDateTime.now();
}

JPA가 INSERT/UPDATE 직전에 자동으로 호출해준다.

LocalDateTime.now()를 코드 곳곳에서 직접 세팅하지 않아도 되고,

시각 누락 실수를 방지할 수 있다.


3. UserSecurityHistory.java

📦 domain/user/entity

추가한 코드

@Entity
@Table(name = "USER_SECURITY_HISTORY")
public class UserSecurityHistory {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Enumerated(EnumType.STRING)
    @Column(name = "event_type", nullable = false, length = 30)
    private SecurityEventType eventType;

    @Column(name = "description", length = 200)
    private String description;

    // 정적 팩토리 메서드
    public static UserSecurityHistory of(
            User user, SecurityEventType eventType, String description) {
        UserSecurityHistory h = new UserSecurityHistory();
        h.user = user;
        h.eventType = eventType;
        h.description = description;
        return h;
    }
}

왜 이 구조로 만들었나

@NoArgsConstructor(access = AccessLevel.PROTECTED) — 기본 생성자를 외부에서 못 쓰게 막았다.

JPA는 내부적으로 기본 생성자가 필요하지만, new UserSecurityHistory() 로 직접 만드는 것을 막아

반드시 of() 정적 팩토리 메서드를 통해서만 생성하도록 강제했다.

@ManyToOne(fetch = FetchType.LAZY) — 이력을 조회할 때 User 전체를 즉시 JOIN하면 불필요한 쿼리가 나간다.

LAZY로 설정해서 실제로 User 정보가 필요할 때만 추가 쿼리가 나가게 했다.

@Enumerated(EnumType.STRING) — DB에 숫자(0, 1) 대신 문자열(PW_CHANGE)로 저장한다.

ENUMTYPE.ORDINAL은 Enum 순서가 바뀌면 기존 데이터가 깨지는 위험이 있어서

STRING 방식을 선택했다.


4. UserSecurityHistoryRepository.java

📦 domain/user/repository

추가한 코드

public interface UserSecurityHistoryRepository
        extends JpaRepository<UserSecurityHistory, Long> {

    List<UserSecurityHistory> findByUserIdOrderByCreatedAtDesc(Long userId);
}

왜 이렇게 만들었나

Spring Data JPA의 메서드 이름 규칙을 사용했다.

findByUserId — user_id 조건 WHERE 절 자동 생성

OrderByCreatedAtDesc — 최신순 정렬 자동 생성

별도 JPQL 없이 메서드 이름만으로 쿼리가 완성된다.


💡 배운 점

Entity에 비즈니스 메서드를 넣는 방식(도메인 모델 패턴)을 처음 적용해봤다.

Service에서 필드를 직접 수정하는 대신 Entity 메서드를 호출하면

조건 변경 시 수정 범위가 줄어들고 테스트하기도 쉬워진다.

@Enumerated(EnumType.STRING) 은 거의 항상 STRING을 써야 한다는 것을 배웠다.

ORDINAL은 Enum 중간에 값이 추가되면 기존 데이터가 전부 깨진다.