4/25(토) 엔티티 작성, User Service 실행 확인, CI 설정

dev_joo·2026년 4월 25일

엔티티 작성

BaseEntity (임시 작성)

package com.pagely.userservice.temp.entity;

/**
 * TODO ⚠️ 임시 구현 :
 * <p>
 * 공통 모듈이 배포되면 이 클래스를 제거하고 공통 모듈 import( com.pagely.common.audit.BaseEntity)
 */
@Getter
@MappedSuperclass
@Access(AccessType.FIELD)
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(name = "created_at", updatable = false, nullable = false)
    protected LocalDateTime createdAt;

    @CreatedBy
    @Column(name = "created_by", updatable = false, nullable = false)
    protected UUID createdBy;

    @LastModifiedDate
    @Column(name = "updated_at", insertable = false)
    protected LocalDateTime updatedAt;

    @LastModifiedBy
    @Column(name = "updated_by", insertable = false)
    protected UUID updatedBy;

    @Column(name = "deleted_at")
    protected LocalDateTime deletedAt;

    @Column(name = "deleted_by")
    protected UUID deletedBy;

    /**
     * Soft delete 처리 (이미 삭제된 경우 무시)
     */
    protected void delete(UUID deletedBy) {
        if (this.deletedAt != null) {
            return;
        }
        this.deletedBy = deletedBy;
        this.deletedAt = LocalDateTime.now();
    }
}

Auditing 유저

시간과 관련된 Audit는 서비스 내부에서 처리할 수 있지만,
Gateway로부터 받아온 헤더 정보를 통해 현재 로그인한 정보를 받아와야했다.
그래서 어떡하지..? 하다가

임시로 빈 값을 넣도록 설정하고
게이트웨이가 헤더를 넣어주는 기능을 구현 한 뒤,
유저 서비스를 게이트웨이와 연동할 때 같이 동작을 확인해 보기로 했다.

JpaAuditingConfig.java

기존에는 이 부분에 SpringSecurity의 Context를 사용한다.

/**
 * JPA에게 "누가 이 데이터를 만들었는지 물어볼 곳"을 알려주는 설정.
 * <p>
 * BaseEntity/BaseUserEntity의 @CreatedDate, @LastModifiedDate, @CreatedBy,@LastModifiedBy 자동 주입을 활성화한다.
 */
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware") // JPA Auditing 기능을 켜기 (auditorAware Bean 사용)
public class JpaAuditingConfig {

    @Bean
    public AuditorAware<java.util.UUID> auditorAware() {
        return new UserContextAuditorAware();
    }
}

아직 인증 부분이 작성되지 않았지만, 커스텀 컨텍스트에서 값을 받아오는것임을 표시하기 위해 UserContextAuditorAware라고 이름을 지었다.

UserContextAuditorAware.java

org.springframework.data.domain.AuditorAware<T> 인터페이스의 Optional<T> getCurrentAuditor()를 구현한다.

/**
 * UserContext(ThreadLocal)에서 유저 UUID를 추출하여 JPA Auditing에 공급한다.
 */
@Slf4j
public class UserContextAuditorAware implements AuditorAware<UUID> {

    @NonNull
    @Override
    public Optional<UUID> getCurrentAuditor() {
        // Filter에서 이미 가공해둔 UserContextHolder(또는 UserContext)를 참조합니다.
        UUID userId = null; // TODO: 임시 구현
        // UUID userId = UserContext.getUserId();

        if (userId == null) {
            // 회원가입 등 인증 정보가 없는 경우
            return Optional.empty();
        }

        return Optional.of(userId);
    }
}

User 엔티티 정의

CreatedBy는 NotNull 이어야 한다.

User 엔티티를 정하려고 하는데 팀 테이블 명세에서BaseEntity의 CreatedBy가 NotNull로 되어있었다.

사실 직전 프로젝트 때는 NotNull 제약이 없었다.
회원가입 시 CreatedBy 필드를 일단 null로 비워둔 채 저장했기 때문에 이번 문제는 전혀 생각치 못했던 문제였다.

데이터 무결성을 위해서 createdBy는 NotNull 제약이 있어야 한다.
사용자가 직접 가입한것인지, 관리자가 생성한것인지, 누가 DB에 침입해서 임의로 밀어넣은 것인지 구분할 수 있는 역할을 한다.
또, 관리자가 생성한 유저 수를 집계할 때도 사용할 수 있다.

User 도메인의 createdBy는 언제 채워줄 수 있을까?

그런데, 일반적인 도메인 엔티티라면 생성한 주체(관리자 또는 시스템)가 분명하겠지만, User 엔티티의 경우 회원가입 시점에 문제가 발생한다.

MASTER 계정이 아닌 일반 회원은 '자기 자신'에 의해 생성된다.
즉, 일반 회원이 가입할 때는 '자기 자신'의 ID를 createdBy에 넣어야 한다.

식별자 생성 전략 (Identifier Generation Strategy)

엔티티의 PK를 어떻게 채울지는 DB 생성 방식(Generated)애플리케이션 할당 방식(Assigned), 두 가지로 나뉜다.

1. DB 생성 방식 (Generated)

MySQL의 AUTO_INCREMENT, JPA의 IDENTITY · SEQUENCE · TABLE 전략처럼 DB가 INSERT 시점에 자동으로 ID를 부여해 주는 방식이다.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 ID를 생성하므로 persist() 시점에는 ID를 알 수 없음 
@Column(columnDefinition = "UUID")
private UUID id;
(INSERT 이후 할당됨)

문제는 INSERT 쿼리가 실행되기 전까지는 객체가 자신의 ID를 알 수 없다는 점이다.

결국 아래처럼 INSERT에 더해 UPDATE까지 두 번의 쿼리를 거칠 수밖에 없다.

persist() → 객체만 등록 (id 없음  createdBy는 어떤 임시값)

flush() → INSERT 실행
        → DB가 id 생성
        → 생성된 id를 다시 받아서 객체에 넣어줌
        
UPDATE → createdBy 임시값을 실제 id로 교체

여기서 또 추가적인 문제로 '쓰기 지연(Write-behind) 무력화'를 생각해볼 수 있다. JPA는 원래 바로 DB에 저장하지 않고 트랜잭션이 끝날 때까지 쿼리를 모아두는 '쓰기 지연'이라는 최적화 매커니즘을 따른다.

하지만 IDENTITY 전략은 INSERT 직후 DB가 생성한 ID를 즉시 반환받아야 하므로, Hibernate는 쿼리를 모아둘 수 없고 persist() 시점에 바로 INSERT를 실행한다. 이 때문에 쓰기 지연이 무력화된다.


+ 번외} Postgres, 숫자를 순차적으로 생성하는 PK일 때: SEQUENCE 전략

PostgreSQL의 SEQUENCE 전략은 DB에서 번호표를(PK 값) 미리 넉넉히 가져와 메모리에 쟁여두는 방식이다.

@Id
@GeneratedValue(strategy GenerationType.SEQUENCE)
@SequenceGenerator(
    name = "my_seq", 
    sequenceName = "user_seq", 
    allocationSize = 50 // 50번의 persist() 동안 DB 네트워크 통신이 0회
)
private Long id;

PostgreSQL의 SEQUENCE는 내부적으로 정수형(Integer/BigInt) 산술 계산을 기반으로 동작하기 때문에 지금의 UUID 타입의 PK에는 사용할 수 없다.

allocationSize가 50이라면 첫 가입자 때만 DB에 들러 50개의 번호를 선점하고, 이후 49명은 DB 통신 없이 즉시 ID를 할당받으므로 응답 속도가 매우 빨라진다.

비록 앱에서 즉시 생성하는 방식에 비하면 미세한 네트워크 비용은 발생하지만, 단순 ID 생성 속도보다는 미리 확보한 ID를 통해 한 번에 여러 데이터를 대량으로 삽입하는 Batch Insert가 가능해지므로 전체적인 시스템 성능을 압도적으로 높여준다.


2. 애플리케이션 할당 방식 (Assigned)

UUID.randomUUID() 등을 통해 애플리케이션 레벨에서 직접 식별자를 생성하고 엔티티에 부여하는 방식이다.

객체를 생성하는 시점에 idcreatedBy동시에 확정할 수 있다.
DB에 묻기 전에 이미 '자기 자신의 ID'를 알고 있으므로, 단 한 번의 INSERT만으로 NOT NULL 제약을 완벽히 준수한 채 영속화를 마칠 수 있다.

쓰기 지연도 정상적으로 동작하고, 추가 UPDATE도 발생하지 않는다.
Audit 필드에 NOT NULL 제약을 걸어 책임 추적성(Accountability) 을 확보하면서, 단일 트랜잭션 커밋 전까지 모든 데이터의 무결성을 보장할 수 있다.

@Id
@Column(columnDefinition = "UUID")
private UUID id;

public static User create(CreateUserCommand cmd) {
    User user = new User();
    user.id = UUID.randomUUID();  // ← 여기서 ID 생성
    user.loginId = cmd.loginId();
    // ... 나머지 필드
    return user;
}

애플리케이션 할당 방식 - new 상태 판단 문제

단, 애플리케이션 할당 방식의 한 가지 단점은 JPA가 엔티티를 '새로운 객체'로 인식하게 만드는 과정이 까다롭다는 점이다.

기본적으로 JPA는 ID 필드가 null일 때만 "아, 이건 새로 저장할 객체구나!"라고 판단한다.
[문서: Spring Data JPA / JPA /Persisting Entities]

하지만 우리가 직접 ID를 채워 넣으면, JPA는 이를 "이미 DB에 있는 객체인데 수정하려고 가져온 건가?"라고 오해할 수 있다.

이 경우 불필요한 SELECT 쿼리가 먼저 실행되거나 merge()가 호출되어 성능 최적화를 방해할 수 있는데, 이를 해결하려면 org.springframework.data.domain.Persistable 인터페이스를 직접 구현해 '이 객체는 확실히 새 것'이라고 알려주는 추가 로직이 필요하다.

애플리케이션 할당 방식 - GenerationType.UUID

이런 번거로움을 해결하면서도 애플리케이션 생성 방식의 이점을 챙기기 위해, 최근에는 아래와 같이 하이버네이트가 ID 생성을 대행해 주는 방식을 주로 사용한다.

@Id
@GeneratedValue(strategy = GenerationType.UUID) // Hibernate 가 엔티티 persist 시점에 UUID 생성
@Column(columnDefinition = "UUID")
private UUID id;

GenerationType.UUID는 애플리케이션에서 UUID를 생성하는것은 같지만, persist() 호출 전까지 ID 필드를 null로 유지한다. 덕분에 JPA가 '새로운 엔티티'임을 자동으로 인식할 수 있어, Persistable 구현 없이도 정상 동작한다.

아래 코드와 같은 일반적인 createdBy 필드의 경우
하이버네이트가 ID를 채워주는 시점과 DB에 들어가기 직전의 찰나를 이용하는 @PrePersist 방식을 통해 채울 수 있다.

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
...
private UUID createdBy;

@PrePersist
public void prePersist() {
    // persist()가 호출되면 하이버네이트가 ID를 먼저 생성합니다.
    // 그 직후 이 메서드가 실행되므로 id를 createdBy에 복사할 수 있습니다.
    if (this.createdBy == null) {
        this.createdBy = this.id;
    }
}

GenerationType.UUID 전략을 사용해도 남은 문제 (Self-Auditing)

그러나 여전히 문제는 남아있었다. 위에서 작성한 BaseEntity에서 Auditing을 통해 createdBy 필드를 채우는 것을 자동화했기 때문이다.

Auditing의 @CreatedBy는 엔티티가 영속화(persist)되는 과정에서 자동으로 필드를 채워준다.

그런데 GenerationType.UUID 전략에서는 하이버네이트가 ID를 생성하는것과 AuditConfig에 등록한 AuditorAware가 값을 꺼내오는 것이 거의 동시에 일어난다.

즉, 다음의 순서가 발생할 수 있다.

[Auditing] createdBy 채움  ❌ (ID 없음)
[Hibernate] ID 생성        ⬅️ 늦음

createdBy 필드는 NOT NULL 제약조건을 걸어두었는데, 하이버네이트가 ID를 생성해서 id 필드에 넣어주기 직전에 Auditing 로직이 실행된다면 createdBynull이라 에러가 난다.


결론: Persistable + PK의 애플리케이션 할당 방식 사용

유저 테이블의 CreatedBy 가 Auditing + NotNull +일 때는
Persistable + PK의 애플리케이션 할당 방식 을 통해 구현한다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity implements Persistable<UUID> {

    @Id
    @Column(columnDefinition = "UUID")
    private UUID id;

    public static User create(String loginId, String nickname) {
        User user = new User();
        user.id = UUID.randomUUID();
        user.createdBy = user.id; // (Self-Audit)
        
        user.loginId = loginId;
        user.nickname = nickname;
        return user;
    }

    /**
     * [Persistable 인터페이스 구현]
     * JPA의 '새로운 엔티티' 판단 기준을 커스텀.
     * ID가 이미 존재하더라도, createdAt이 null이면 신규 엔티티(INSERT)로 인식한다.
     */
    @Override
    public boolean isNew() {
        return getCreatedAt() == null; 
    }
}

User.java

VO (Value Object) 설계

VO 장점

VO를 도입하면 생성자 단계에서 필드를 검증하거나 정책을 엔티티 밖으로 분리할 수 있다.
즉, 엔티티 안에 들어온 객체는 이미 안전하게 처리된 상태다. 라는 전제로 비즈니스 로직에만 집중할 수 있다.

password 필드

password 비밀번호를 단순히 String으로 두기 보다 평문(Raw Password)이 유입되는 것을 완벽히 차단하기 위해 VO를 도입해야겠다고 생각했다.

특히 비밀번호는 VO 안에 들어갈 로직(of(), matches() 등...)이 명확히 존재했기 때문에 도입 가치가 충분하다.

email, phone 필드

이 외에도 email,phone의 경우 자체 정책(이메일 형식, 전화번호 형식 등)이 있지만, 조회가 자주 일어나는 필드라 생각했고, QueryDSL 도입 시 user.email.value.eq(email) 처럼 경로가 길어지는 불편함이 생길 수 있어 Stirng으로 정했다.

rating 필드

rating은 자체 정책(0점 미만 자동 정지 등)이 있어 VO가 맞지만, VO 도입이 DB 스키마 구조를 바꾸는 것은 아니므로 지금은 MVP 범위에 집중하고 나중에 평가 시스템을 도입할 때 변경하기로 했다.

Password VO

/**
 * 해시된 비밀번호를 표현하는 Value Object.
 */
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {

    @Column(name = "password", nullable = false, length = 255)
    private String hashedValue;

    private Password(String hashedValue) {
        this.hashedValue = hashedValue;
    }

    /**
     * 평문을 해싱해서 Password 객체 생성.
     *
     * @param rawPassword 평문 (정책 검증 후 즉시 해싱됨)
     * @param encoder     해싱에 사용할 인코더 (BCrypt 등)
     */
    public static Password of(String rawPassword, PasswordEncoder encoder) {
        validatePolicy(rawPassword);
        return new Password(encoder.encode(rawPassword));
    }

    /**
     * 이미 해싱된 값 (DB에서 읽어올 때) 두 번 해싱 방지
     */
    public static Password fromHashed(String hashedValue) {
        return new Password(hashedValue);
    }

    /**
     * 평문 비밀번호가 이 객체의 해시와 일치하는지 검증.
     */
    public boolean matches(String rawPassword, PasswordEncoder encoder) {
        return encoder.matches(rawPassword, this.hashedValue);
    }

    /**
     * 정책 검증 : NIST 가이드
     */
    private static void validatePolicy(String rawPassword) {
        if (rawPassword == null) {
            throw new IllegalArgumentException("비밀번호는 필수입니다.");
        }

        String trimmed = rawPassword.trim();

        if (trimmed.length() < 10 || trimmed.length() > 64) {
            throw new IllegalArgumentException("비밀번호는 10자 이상 64자 이하여야 합니다.");
        }
        if (trimmed.contains(" ")) {
            throw new IllegalArgumentException("비밀번호에 공백은 사용할 수 없습니다.");
        }
    }

    // a.equals(b) (a가 null이면 NPE) -> Objects.equalse(a,b) 사용
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Password that)) {
            return false;
        }
        return Objects.equals(hashedValue, that.hashedValue);
    }

    @Override
    public int hashCode() {
        return Objects.hash(hashedValue);
    }

    /**
     * 해시 값이라도 노출 안 함. (로컬 브루트포스 방지)
     */
    @Override
    public String toString() {
        return "Password{****}";
    }
}

완성된 User 엔티티:

@Entity
@Getter
@Table(name = "p_users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity implements Persistable<UUID> {

    @Id
    @Column(columnDefinition = "UUID")
    private UUID id;

    @Column(name = "login_id", nullable = false, length = 50)
    private String loginId;

    @Embedded
    private Password password;

    @Column(name = "name", nullable = false, length = 100)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false, length = 20)
    private Role role;

    @Column(name = "nickname", nullable = false, length = 30)
    private String nickname;

    @Column(name = "email", nullable = false, length = 100)
    private String email;

    @Column(name = "phone", nullable = false, length = 20)
    private String phone;

    @Enumerated(EnumType.STRING)
    @Column(name = "gender", nullable = false, length = 10)
    private Gender gender;

    @Column(name = "birth_date", nullable = false)
    private LocalDate birthDate;

    @Column(name = "rating", nullable = false)
    private Integer rating;

    @Column(name = "is_suspended", nullable = false)
    private Boolean isSuspended;

    // ====================================================================
    // 팩토리 메서드
    // ====================================================================

    /**
     * 신규 유저 생성.
     *
     * @param hashedPassword BCrypt 등으로 해싱된 비밀번호 (평문 금지)
     * @return 새 User (ID 자동 생성, role=USER, rating=1000, isSuspended=false)
     */
    public static User create(
            String loginId,
            String email,
            Password hashedPassword,
            String name,
            String nickname,
            String phone,
            Gender gender,
            LocalDate birthDate
    ) {
        User user = new User();
        user.id = UUID.randomUUID();
        user.createdBy = user.id;
        user.loginId = loginId;
        user.email = email;
        user.password = hashedPassword;
        user.name = name;
        user.role = Role.USER;
        user.nickname = nickname;
        user.phone = phone;
        user.gender = gender;
        user.birthDate = birthDate;
        user.rating = 1000;
        user.isSuspended = false;
        return user;
    }

    // ====================================================================
    // Persistable 구현
    // ====================================================================

    /**
     * Spring Data JPA가 신규 vs 기존을 판정. createdAt이 null이면 아직 영속화되지 않은 신규 엔티티 → INSERT. createdAt이 있으면 이미 영속화된 기존 엔티티 →
     * UPDATE.
     *
     * <p>id가 항상 미리 생성되는 본 엔티티의 특성상,
     * 기본 isNew 판정(id null 검사)을 그대로 쓸 수 없어 직접 구현.</p>
     */
    @Override
    public boolean isNew() {
        return getCreatedAt() == null;
    }
}

User Service 실행 확인

헬스체크

curl -i http://localhost:19001/actuator/health
HTTP/1.1 200 
Content-Type: application/vnd.spring-boot.actuator.v3+json
Transfer-Encoding: chunked
Date: Sat, 25 Apr 2026 15:26:46 GMT
{"status":"UP"}

Eureka 등록 확인

http://localhost:8000

Gateway 경유 검증

# Gateway → User Service 라우팅
curl -i http://localhost:8080/api/v1/users
HTTP/1.1 404 Not Found
transfer-encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Date: Sat, 25 Apr 2026 15:34:50 GMT

{"timestamp":"2026-04-25T15:34:50.399+00:00","status":404,"error":"Not Found","path":"/api/v1/users"}%      
curl -i http://localhost:8080/api/v1/auth/token

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "timestamp": "2026-04-25T15:36:31.127+00:00",
  "status": 404,
  "error": "Not Found",
  "path": "/api/v1/auth/token"
}


curl -i http://localhost:8080/something

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "timestamp": "2026-04-25T15:36:31.169+00:00",
  "path": "/something",
  "status": 404,
  "error": "Not Found",
  "requestId": "a73bc68c-3"
}

CI 설정

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글