
이전 작업에서는 Flowbit의 상태 변경 구조를 다시 점검했다.
Flowbit은 단순히 Task의 현재 상태만 저장하는 시스템이 아니라,
상태가 어떻게 바뀌었는지를 TaskEvent로 함께 기록하는 구조다.
그래서 지난 작업에서는 Task 상태 변경과 TaskEvent 저장을 하나의 트랜잭션으로 묶고,
현재 상태와 이벤트 이력이 서로 어긋나지 않도록 정리했다.
그 작업을 마치고 나니 다음으로 잡아야 할 부분이 보였다.
→ 이 상태 변화는 누가 만든 것인가?
지금까지 Flowbit에는 Project, Task, TaskEvent는 있었지만
사용자 개념은 아직 없었다.
Task를 누가 만들었는지,
상태를 누가 변경했는지,
나중에 Workspace 안에서 누가 어떤 권한을 가지는지 표현하려면
먼저 User 도메인이 필요했다.
그래서 이번 작업의 목표는 명확했다.
Flowbit에 User 도메인을 추가하고, 회원가입부터 로그인, JWT 발급까지 인증 흐름의 기본 구조를 만든다.
처음에는 Redis 캐싱을 먼저 붙일까 고민했다.
Project Analysis API처럼 반복 조회가 많은 기능은
캐싱을 붙이면 성능 개선 포인트를 만들 수 있기 때문이다.
하지만 Flowbit의 핵심을 다시 보면,
지금 더 먼저 필요한 것은 성능보다 “이벤트의 의미”를 명확히 하는 일이었다.
Flowbit의 핵심 구조는 다음과 같다.
Task → 현재 상태
TaskEvent → 상태 변화 이력
여기서 TaskEvent는 단순한 로그가 아니다.
작업이 생성됐는지,
진행 중으로 바뀌었는지,
완료됐는지,
보류됐는지를 기록하는 핵심 데이터다.
그런데 이벤트를 더 의미 있게 만들려면
이 질문에 답할 수 있어야 한다.
누가 이 상태를 변경했는가?
그래서 User 도메인을 먼저 추가하기로 했다.
이번 단계에서 User를 붙이면
이후에는 다음 구조로 자연스럽게 확장할 수 있다.
Task.createdBy
Task.assigneeId
TaskEvent.createdBy
WorkspaceMember.user
즉, User는 단순히 로그인용 테이블이 아니라
Flowbit에서 “행위자”를 표현하기 위한 시작점이다.
먼저 User 엔티티를 만들었다.
@Entity
@Getter
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
private LocalDateTime createdAt;
public User(String email, String password, String name, LocalDateTime createdAt) {
this.email = email;
this.password = password;
this.name = name;
this.createdAt = createdAt;
}
}
테이블 이름은 user가 아니라 users로 지정했다.
일부 DB에서는 user가 예약어처럼 취급될 수 있기 때문에
엔티티 이름과 테이블 이름을 분리해서 충돌 가능성을 줄였다.
그리고 기본 생성자는 protected로 열어두었다.
JPA는 DB에서 엔티티를 조회할 때 내부적으로 기본 생성자를 사용한다.
하지만 외부 코드에서 new User()처럼 비어 있는 User 객체를 아무렇게나 만들 수 있게 두고 싶지는 않았다.
그래서 JPA는 사용할 수 있지만,
일반 코드에서는 불완전한 객체 생성을 막을 수 있도록 다음처럼 작성했다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
이 구조는 기존 Project, Task 엔티티에서 사용하던 방식과도 맞춰두었다.
User 엔티티를 만든 뒤에는 회원가입 API를 추가했다.
흐름은 단순하게 잡았다.
POST /api/auth/signup
회원가입 요청은 email, password, name을 받는다.
{
"email": "test@example.com",
"password": "1234",
"name": "홍길동"
}
서비스에서는 먼저 이메일 중복 여부를 확인한다.
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
그다음 User를 생성해서 저장한다.
다만 이때 중요한 점이 있었다.
비밀번호는 절대 평문으로 저장하면 안 된다.
그래서 바로 다음 단계에서 PasswordEncoder를 적용했다.

처음 회원가입 흐름만 보면
요청으로 받은 password를 그대로 User에 넣으면 될 것처럼 보인다.
하지만 이렇게 하면 DB에 사용자의 비밀번호가 그대로 저장된다.
password = 1234
이건 인증 시스템에서 가장 피해야 하는 구조다.
그래서 Spring Security의 PasswordEncoder를 등록하고,
BCrypt 방식으로 비밀번호를 암호화해서 저장하도록 변경했다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
회원가입 시에는 다음처럼 원본 비밀번호를 인코딩한다.
String encodedPassword = passwordEncoder.encode(request.getPassword());
그리고 User에는 원본 비밀번호가 아니라
인코딩된 비밀번호를 저장한다.
User user = new User(
request.getEmail(),
encodedPassword,
request.getName(),
LocalDateTime.now()
);
DB에서 확인하면 password 컬럼에는 1234가 아니라
BCrypt로 인코딩된 문자열이 저장된다.
$2a$10$...
이 단계에서 회원가입은 단순 저장 API가 아니라,
최소한의 인증 보안 흐름을 갖춘 API가 되었다.

다음으로 로그인 API를 만들었다.
POST /api/auth/login
로그인 요청은 email과 password를 받는다.
{
"email": "test@example.com",
"password": "1234"
}
로그인 흐름은 다음과 같다.
1. email로 User를 조회한다.
2. 입력받은 password와 DB의 encoded password를 비교한다.
3. 검증에 성공하면 로그인 응답을 반환한다.
여기서 중요한 부분은 비밀번호 비교 방식이다.
BCrypt는 같은 비밀번호를 인코딩해도
매번 다른 결과가 나올 수 있다.
그래서 로그인할 때 다음처럼 비교하면 안 된다.
passwordEncoder.encode(request.getPassword()).equals(user.getPassword())
대신 반드시 matches()를 사용해야 한다.
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
또한 로그인 실패 메시지는 이메일이 틀렸는지,
비밀번호가 틀렸는지 구체적으로 나누지 않았다.
throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
로그인 실패 이유를 너무 자세히 알려주면
어떤 이메일이 가입되어 있는지 추측할 수 있기 때문이다.
로그인 API까지 만들고 나면
사용자 검증은 가능해진다.
하지만 로그인에 성공했다는 사실을
클라이언트가 이후 요청에서도 계속 증명하려면 토큰이 필요하다.
그래서 로그인 성공 시 JWT accessToken을 발급하도록 구현했다.
이번 단계에서 JWT는 “발급”까지만 다뤘다.
로그인 성공
→ JWT 생성
→ accessToken 응답
아직 토큰을 검사해서 API 접근을 막거나,
현재 로그인 사용자를 꺼내는 구조까지는 적용하지 않았다.
그 부분은 다음 작업에서 JWT Filter를 붙이며 다룰 예정이다.
JWT 생성 책임은 JwtTokenProvider로 분리했다.
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long accessTokenExpiration;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiration}") long accessTokenExpiration
) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpiration = accessTokenExpiration;
}
public String createAccessToken(User user) {
Date now = new Date();
Date expiration = new Date(now.getTime() + accessTokenExpiration);
return Jwts.builder()
.subject(String.valueOf(user.getId()))
.claim("email", user.getEmail())
.claim("name", user.getName())
.issuedAt(now)
.expiration(expiration)
.signWith(key)
.compact();
}
}
여기서 subject에는 User의 id를 넣었다.
.subject(String.valueOf(user.getId()))
나중에 JWT Filter를 만들면
이 subject에서 userId를 꺼내 현재 로그인 사용자를 찾는 흐름으로 이어질 수 있다.
처음에는 JWT secret key를 코드에 직접 둘 수도 있었다.
하지만 secret key는 말 그대로 비밀값이다.
그래서 Java 코드에 하드코딩하지 않고
application.yml에서 환경변수 기반으로 읽도록 분리했다.
jwt:
secret: ${JWT_SECRET:flowbit-local-jwt-secret-key-must-be-at-least-32-bytes-long}
access-token-expiration: 3600000
이 설정은 다음 의미를 가진다.
JWT_SECRET 환경변수가 있으면 그 값을 사용한다.
없으면 로컬 개발용 기본값을 사용한다.
운영 환경에서는 실제 secret을 환경변수로 주입하고,
코드에는 설정을 읽는 구조만 남긴다.
@Value("${jwt.secret}") String secret
이렇게 해두면 JWT secret을 Git에 직접 올리지 않으면서도
로컬 개발 환경에서는 바로 실행할 수 있다.
기존 로그인 응답은 사용자 정보만 반환했다.
{
"userId": 1,
"email": "test@example.com",
"name": "아현"
}
JWT 발급 구조를 추가한 뒤에는
로그인 응답에 accessToken이 포함된다.
{
"userId": 1,
"email": "test@example.com",
"name": "아현",
"accessToken": "eyJhbGciOiJIUzI1NiJ9..."
}
회원가입 응답과 로그인 응답은 성격이 다르기 때문에
응답 DTO도 분리했다.
AuthResponse → 회원가입 응답
LoginResponse → 로그인 응답 + accessToken
회원가입은 “사용자가 생성되었다”가 핵심이고,
로그인은 “인증에 성공했고 토큰이 발급되었다”가 핵심이다.
그래서 로그인 응답에만 accessToken을 포함하도록 했다.

이번 작업으로 Flowbit의 인증 기본 흐름은 다음처럼 정리됐다.
회원가입
→ 이메일 중복 확인
→ 비밀번호 BCrypt 인코딩
→ User 저장
→ 비밀번호 제외한 사용자 정보 응답
로그인
→ 이메일로 User 조회
→ passwordEncoder.matches()로 비밀번호 검증
→ JWT accessToken 발급
→ LoginResponse 반환
아직 모든 API가 JWT 인증을 요구하는 상태는 아니다.
현재 단계는 인증의 첫 번째 절반이다.
토큰을 발급하는 단계
다음 단계에서는 이 토큰을 실제 요청에서 사용해야 한다.
Authorization: Bearer {accessToken}
그리고 서버는 이 토큰을 검증해서
API 요청을 허용할지 결정하게 된다.
이번 작업은 겉으로 보면 회원가입과 로그인 API를 만든 작업이다.
하지만 Flowbit 관점에서는 단순 인증 기능 추가보다 의미가 더 크다.
기존 Flowbit은 Task와 TaskEvent를 통해
작업의 현재 상태와 상태 변화 이력을 기록할 수 있었다.
하지만 아직 다음 질문에는 답하지 못했다.
누가 이 작업을 만들었는가?
누가 상태를 변경했는가?
이번에 User 도메인을 추가하면서
이 질문에 답할 수 있는 기반이 생겼다.
그리고 회원가입, 로그인, PasswordEncoder, JWT 발급까지 구현하면서
앞으로 Workspace, RBAC, TaskEvent actor 연결로 확장할 준비도 시작됐다.
오늘 작업을 한 문장으로 정리하면 다음과 같다.
Flowbit에 상태 변화의 행위자를 연결하기 위한 User/JWT 기본 구조를 추가했다.
다음 작업에서는 JWT Filter를 추가해서,
발급된 accessToken을 실제 API 인증 흐름에 연결해볼 예정이다.