2026. 6. 4. 19:34ㆍ프로젝트/Trip-Linker 팀 프로젝트
auth 도메인 전체 구현 — 로그인·토큰·비밀번호 재설정
📋 한줄 요약
로그인·토큰 재발급·로그아웃·비밀번호 재설정 기능의 auth 도메인 전체를 구현했다.
JWT 발급 부분은 팀에서 JwtProvider를 완성하면 연결할 수 있도록 주석으로 자리를 잡아뒀다.
🎯 왜 만들었나
로그인 시 JWT를 발급하고, 5회 실패 시 계정을 잠그고, 비밀번호 재설정 링크를 이메일로 보내는 보안 흐름 전체가 필요했다.
auth 도메인에 필요한 Entity / Repository / DTO / Service / Controller를 한 번에 만들었다.
📁 작업 순서 (파일별)
1. LoginRequestDto.java
📦 domain/auth/dto
@Getter
@NoArgsConstructor
public class LoginRequestDto {
private String username;
private String password;
}
왜 만들었나
로그인 요청 Body를 Map<String, String>으로 받으면 타입 안전성이 없고 오타가 나도 컴파일 에러가 안 난다.
전용 DTO로 분리해서 필드명을 명시적으로 고정했다.
2. TokenResponseDto.java
📦 domain/auth/dto
@Getter
@AllArgsConstructor
public class TokenResponseDto {
private String accessToken;
private String refreshToken;
private boolean pwChangeRecommended; // 90일 경과 시 true → 프론트에서 모달 표시
}
pwChangeRecommended 를 추가한 이유
90일 비밀번호 미변경 사용자에게 변경 권장 모달을 띄워야 한다.
별도 API를 만드는 대신 로그인 응답에 플래그 하나를 포함시켜서 추가 요청 없이 처리했다.
3. RefreshToken.java
📦 domain/auth/entity
@Entity
@Table(name = "REFRESH_TOKENS")
public class RefreshToken {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token_hash", nullable = false, length = 255)
private String tokenHash; // 원문이 아닌 해시값만 저장
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt; // 7일 유효
public static RefreshToken of(User user, String tokenHash) {
RefreshToken token = new RefreshToken();
token.user = user;
token.tokenHash = tokenHash;
token.expiresAt = LocalDateTime.now().plusDays(7);
return token;
}
}
토큰 원문이 아닌 해시값을 저장하는 이유
DB가 탈취당해도 해시값만으로는 원본 토큰을 복원할 수 없다.
클라이언트에서 보낸 토큰을 해시화한 뒤 DB의 해시값과 비교하는 방식으로 검증한다.
Hard Delete 방식을 선택한 이유
로그아웃·탈퇴 시 토큰을 Soft Delete(deleted_at 컬럼)로 두면
만료된 토큰이 테이블에 계속 쌓인다. 보안 토큰은 무효화 즉시 완전 삭제가 맞다.
4. PasswordResetToken.java
📦 domain/auth/entity
@Entity
@Table(name = "PASSWORD_RESET_TOKENS")
public class PasswordResetToken {
@Column(nullable = false, unique = true, length = 255)
private String token; // UUID 기반 랜덤 토큰
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt; // 30분 유효
@Column(name = "is_used", nullable = false)
private boolean isUsed = false; // 1회 사용 후 재사용 불가
// 유효한 토큰인지 확인 (만료 + 사용 여부 동시 체크)
public boolean isValid() {
return !isUsed && LocalDateTime.now().isBefore(expiresAt);
}
// 비밀번호 변경 완료 후 호출
public void markUsed() {
this.isUsed = true;
}
}
isValid() / markUsed() 를 Entity 안에 넣은 이유
유효성 체크 조건(만료 시각 + 사용 여부)이 바뀌어도 이 파일 한 곳만 수정하면 된다.
Service에서 조건을 직접 비교하면 같은 조건이 여러 곳에 흩어질 수 있다.
5. RefreshTokenRepository.java
📦 domain/auth/repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByTokenHash(String tokenHash); // 토큰 재발급 시 조회
void deleteByUserId(Long userId); // 로그아웃·탈퇴 시 전체 삭제
}
deleteByUserId 를 쓴 이유
한 유저가 여러 기기에서 로그인하면 Refresh Token이 여러 개 생긴다.
로그아웃·탈퇴 시 userId 기준으로 전부 삭제해야 다른 기기에서도 자동 로그아웃된다.
6. PasswordResetTokenRepository.java
📦 domain/auth/repository
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {
Optional<PasswordResetToken> findByToken(String token); // 이메일 링크의 토큰값으로 조회
}
7. AuthService.java
📦 domain/auth/service
public interface AuthService {
TokenResponseDto login(LoginRequestDto dto); // 로그인 → JWT 발급
TokenResponseDto refresh(String refreshToken); // 토큰 재발급
void logout(Long userId); // Refresh Token 삭제
void sendPasswordResetEmail(String email); // 재설정 이메일 발송
void resetPassword(String token, String newPassword); // 실제 비밀번호 변경
}
8. AuthServiceImpl.java
📦 domain/auth/service
로그인 흐름 (login)
public TokenResponseDto login(LoginRequestDto dto) {
// 1. 유저 조회
User user = userRepository.findByUsername(dto.getUsername())
.orElseThrow(...);
// 2. 계정 잠금 확인
if (user.isLocked()) {
throw new IllegalStateException("5분 후 다시 시도해주세요.");
}
// 3. 비밀번호 불일치 → 실패 횟수 +1 (5회 시 자동 잠금)
if (!passwordEncoder.matches(dto.getPassword(), user.getPasswordHash())) {
user.increaseLoginFailCount();
throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.");
}
// 4. 성공 → 실패 횟수 초기화
user.resetLoginFail();
// 5. 90일 미변경 여부 체크
boolean pwChangeRecommended = false;
if (user.getLastPwChangedAt() != null) {
long days = ChronoUnit.DAYS.between(user.getLastPwChangedAt(), LocalDateTime.now());
pwChangeRecommended = days >= 90;
}
// JWT는 JwtProvider 완성 후 연결 예정 (주석으로 자리 확보)
return new TokenResponseDto("access-token-placeholder", "refresh-token-placeholder", pwChangeRecommended);
}
비밀번호 재설정 흐름 (sendPasswordResetEmail → resetPassword)
// 1단계: 이메일로 링크 발송
public void sendPasswordResetEmail(String email) {
// 소셜 전용 계정(passwordHash == null) 차단
String token = UUID.randomUUID().toString();
passwordResetTokenRepository.save(PasswordResetToken.of(user, token));
// TODO: emailService.sendResetEmail(email, token);
}
// 2단계: 링크 클릭 후 실제 변경
public void resetPassword(String token, String newPassword) {
PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token)
.orElseThrow(...);
if (!resetToken.isValid()) { // 만료 or 이미 사용된 토큰 차단
throw new IllegalStateException("만료되었거나 이미 사용된 토큰입니다.");
}
user.updatePassword(passwordEncoder.encode(newPassword));
resetToken.markUsed(); // 재사용 방지
// 보안 이력 기록
userSecurityHistoryRepository.save(
UserSecurityHistory.of(user, SecurityEventType.PW_CHANGE, "비밀번호 재설정 완료")
);
}
왜 UUID.randomUUID()로 토큰을 만들었나
예측 불가능한 128비트 랜덤값이라 외부에서 추측이 불가능하다.
별도 라이브러리 없이 Java 표준으로 쓸 수 있어서 선택했다.
9. AuthController.java
📦 domain/auth/controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
// POST /api/auth/login
@PostMapping("/login")
public ResponseEntity<TokenResponseDto> login(@RequestBody LoginRequestDto dto) { ... }
// POST /api/auth/refresh
@PostMapping("/refresh")
public ResponseEntity<TokenResponseDto> refresh(@RequestBody Map<String, String> body) { ... }
// POST /api/auth/logout
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestParam Long userId) { ... }
// POST /api/auth/password/reset-request
@PostMapping("/password/reset-request")
public ResponseEntity<Void> sendResetEmail(@RequestBody Map<String, String> body) { ... }
// PATCH /api/auth/password/reset
@PatchMapping("/password/reset")
public ResponseEntity<Void> resetPassword(@RequestBody Map<String, String> body) { ... }
}
logout에 @RequestParam을 쓴 이유
JWT가 아직 완성되지 않아서 토큰에서 userId를 꺼낼 수가 없었다.
임시로 @RequestParam으로 받고, JwtProvider 완성 후 @AuthenticationPrincipal로 교체할 예정이다.
⚠️ 현재 미완성 부분 (TODO)
항목 현재 상태 완성 조건
| JWT 발급 | placeholder 문자열 반환 | 홍은표 JwtProvider 완성 후 연결 |
| 이메일 발송 | System.out.println으로 출력 | EmailService 완성 후 교체 |
| logout userId | @RequestParam으로 임시 수신 | JWT 완성 후 @AuthenticationPrincipal로 교체 |
💡 배운 점
Refresh Token은 원문이 아닌 해시값을 DB에 저장해야 탈취 시 피해를 최소화할 수 있다.
비밀번호 재설정 토큰처럼 상태(유효/만료/사용됨)가 있는 경우 isValid(), markUsed() 같은 상태 변경 메서드를 Entity 안에 넣으면 Service가 단순해진다.
JWT 같은 외부 의존이 미완성일 때는 placeholder를 명시적으로 남기고 TODO 주석으로 자리를 잡아두면 나중에 연결하기 쉽다.
'프로젝트 > Trip-Linker 팀 프로젝트' 카테고리의 다른 글
| Trip-Linker 개발일지 6 - 플래너 API 연동 및 UI 개선 — goPlanStep 검증, 날씨 팝업 (0) | 2026.06.04 |
|---|---|
| Trip-Linker 개발일지 5 - User.java — updateNickname·withdraw 메서드 추가 (0) | 2026.06.04 |
| Trip-Linker 개발일지 3 - 프론트 리팩토링 — MOCK 위치, go() 렌더러, CSS 버그 (0) | 2026.06.04 |
| Trip-Linker 개발일지 2 - 계정 보안 이력 관리 Entity·Repository 구현 (0) | 2026.06.04 |