[사이드 프로젝트] 디지털 굿즈 플랫폼 개발기 #2 - JPA 엔티티 설계와 기술적 디테일

1. 들어가며

지난 포스팅에서 마플샵과 차별화된 디지털 굿즈 플랫폼 Universe Pick의 DB 설계를 진행했다. 총 22개의 테이블이 나왔고, 이제 이 설계도를 실제 Spring Boot(JPA) 엔티티 코드로 옮기는 작업을 진행하겠다. 모든 테이블을 설명하면 내용이 너무 많아 일단 이번 편에서는 member와 관련된 테이블들을 구현하겠다. 단순히 테이블을 매핑하는 것이 아니라, "안전한 객체 사용""데이터 무결성"을 위해 적용한 몇 가지 JPA Best Practice설계 의도를 기록한다.


2. 개발 환경 세팅 (Docker + Spring Boot)

로컬 DB 설치의 번거로움을 줄이기 위해 Docker Compose를 사용했다. PostgreSQL 15 버전을 컨테이너로 띄우고, Spring Boot의 application.yml에서 ddl-auto: update 옵션을 활성화했다. 이로써 내가 Java 코드(Entity)를 수정하고 서버를 재시작하면, 변경된 내용이 자동으로 DB 스키마에 반영된다.

docker-compose.yaml

version: '3.8'

services:
  db:
    image: postgres:15-alpine
    container_name: <containerName>
    restart: always
    ports:
      - "5433:5432" # 로컬 포트 5433을 컨테이너 포트 5432에 연결
    environment:
      POSTGRES_DB: <dbName>        # 데이터베이스 이름
      POSTGRES_USER: <username>    # 접속 계정 ID
      POSTGRES_PASSWORD: <password>  # 접속 비밀번호
      TZ: Asia/Seoul              # 타임존 설정 (한국 시간)
    volumes:
      - ./db/data:/var/lib/postgresql/data # 프로젝트 폴더 내 db/data에 데이터 저장
    command: postgres -c 'max_connections=200' # 커넥션 수 늘리기 (선택사항)
  pgadmin:
    image: dpage/pgadmin4
    container_name: <containerName2>
    environment:
      PGADMIN_DEFAULT_EMAIL: <email>
      PGADMIN_DEFAULT_PASSWORD: <pw>
    ports:
      - "5050:80"
    depends_on:
      - db

application.yaml 설정

spring:
  application:
    name: DigitalPhotocard
  # 1. 데이터베이스 연결 설정
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:<port>/<dbName>
    username: <username>      # docker-compose.yml과 일치해야 함
    password: <password>  # docker-compose.yml과 일치해야 함
  # 2. JPA & Hibernate 설정 (핵심!)
  jpa:
    hibernate:
      ddl-auto: update       # [중요] 엔티티 변경사항을 DB 스키마에 자동 반영
    show-sql: true           # 실행되는 SQL 쿼리를 콘솔에 출력
    properties:
      hibernate:
        format_sql: true     # SQL을 보기 좋게 줄바꿈해서 출력
        dialect: org.hibernate.dialect.PostgreSQLDialect
    open-in-view: false      # (선택) OSIV 패턴 끄기 (성능 최적화 시 권장)
  # 3. 한글 인코딩 설정
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true
# 4. 로깅 레벨 설정 (디버깅용)
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type.descriptor.sql: trace # 쿼리 파라미터 값(?에 들어가는 값) 보임

도커를 이용해 PostgreSQL을 이용한 이유로는 우리가 실제 배포를 하게 된다면 사용할 DB가 PostgreSQL이기도 하고 h2를 사용하는 예제가 많기는 했지만 h2에서는 되던 것이 postgreSQL에서는 안될 수 있는 가능성이 있어 사용하게 되었다.


3. Member 도메인

Universe Pick 프로젝트는 도메인(기능)을 기준으로 패키지를 나누는 방식을 택했다. Member(회원), Store(상점), Commerce(주문/결제) 등 업무 도메인이 최상위 폴더가 되고, 그 안에 해당 업무를 처리하기 위한 Controller, Entity, Service가 함께 모여 있다.

com.myproject.cardplatform
└── member
    └── domain
        ├── User.java          # 회원 본체
        ├── Creator.java       # 크리에이터 추가 정보 (User와 1:1)
        ├── SocialAccount.java # 소셜 로그인 정보 (User와 1:N)
        ├── Follow.java        # 팔로우 관계 (User와 User의 N:M)
        ├── UserRole.java      # 권한 (Enum)
        └── UserStatus.java    # 상태 (Enum)

기능별 패키지 구조 (Package-by-Feature) 선택 이유

더보기

① 높은 응집도 (High Cohesion) "결제 로직을 고치고 싶다"면 그냥 commerce 패키지만 열면 된다. 관련된 코드(Entity, Service, Controller, DTO)가 한 곳에 모여있어 코드 파악이 빠르고, 다른 도메인의 코드를 실수로 건드릴 위험이 적다.

② 마이크로서비스(MSA)로의 확장성 이게 가장 큰 이유다. 지금은 하나의 서버(Monolith)지만, 나중에 트래픽이 터져서 "결제 서버만 따로 떼어내고 싶다"는 상황이 오면? 계층형 구조라면 코드 전체를 뜯어고쳐야 하지만, 기능형 구조에서는 commerce 폴더만 뚝 떼어내서 별도 프로젝트로 옮기면 끝이다.

③ 가시성 (Visibility) 패키지 구조만 딱 봐도 "아, 이 프로젝트는 회원, 상점, 결제, 전시 기능을 하는구나"라고 바로 파악할 수 있다. 프로젝트 구조 자체가 곧 기능 명세서 역할을 한다.

User 엔티티

package com.myproject.cardplatform.member.domain;

import com.myproject.cardplatform.global.common.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Entity // 1. JPA가 이 클래스를 관리하도록 지정 (DB 테이블과 매핑됨)
@Getter // 2. 모든 필드의 Getter 메서드 자동 생성 (user.getEmail() 등)
@Table(name = "users") // 3. DB 테이블 이름을 강제로 'users'로 지정 (PostgreSQL에서 'user'는 예약어라 에러 남)
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 4. JPA용 '빈 생성자'를 만들되, 외부에서 함부로 new User() 못하게 막음
public class User extends BaseTimeEntity { // 5. BaseTimeEntity 상속: created_at, updated_at 자동 관리

    @Id // 6. Primary Key(기본키) 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 7. Auto Increment 설정 (DB가 알아서 1, 2, 3... 번호 매김)
    private Long id;

    @Column(nullable = false, unique = true) // 8. DB 컬럼 제약조건: null 불가, 중복 불가(이메일이니까)
    private String email;

    @Column(nullable = false, length = 50) // 9. 길이 제한 50자
    private String nickname;

    @Column(columnDefinition = "TEXT") // 10. 긴 문자열 저장 (Postgres의 TEXT 타입)
    private String profileImageUrl;

    @Enumerated(EnumType.STRING) // 11. 중요! Enum을 DB에 저장할 때 숫자가 아닌 "문자열('USER')"로 저장. (순서 꼬임 방지)
    @Column(nullable = false)
    private UserRole role;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private UserStatus status;

    // 12. 양방향 매핑 (선택사항)
    // mappedBy = "user": 나는 연관관계의 주인이 아님. (Creator 테이블의 user 필드가 주인이다)
    // CascadeType.ALL: 내가(User) 삭제되면 내 Creator 정보도 같이 삭제해라.
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private Creator creator;

    // 13. 1:N 매핑
    // List를 초기화(= new ArrayList<>()) 해두는 것이 NullPointerException 방지에 좋음
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<SocialAccount> socialAccounts = new ArrayList<>();

    // 14. @Builder: 객체 생성을 명확하게 하기 위함
    // 사용법: User.builder().email("a@a.com").role(UserRole.USER).build();
    // 생성자에서 role이 null이면 기본값으로 USER를 넣어주는 안전장치 포함
    @Builder
    public User(String email, String nickname, String profileImageUrl, UserRole role) {
        this.email = email;
        this.nickname = nickname;
        this.profileImageUrl = profileImageUrl;
        this.role = role != null ? role : UserRole.USER;
        this.status = UserStatus.ACTIVE;
    }
    
    // 15. 비즈니스 로직 메서드 (Setter 대신 사용)
    // user.setRole(CREATOR)라고 하는 것보다, 의도가 명확함
    public void promoteToCreator() {
        this.role = UserRole.CREATOR;
    }
}

위 코드는 User 엔티티 코드이다. 이 코드를 작성해보면서 내가 항상 스프링으로 엔티티 코드를 작성하면서 궁금했거나 왜 이런 코드를 사용하는지 이유를 알아보고 가면 좋겠다고 생각했다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)

일단 롬복을 사용하면서 이 생성자 관련 어노테이션이 많이 나왔는데 이 어노테이션들 특성과 엔티티에서는 어떤 것을 사용해야 할지 비교해 보겠다.

어노테이션 생성되는 생성자 주요 특징 Entity 사용 추천
@NoArgsConstructor 매개변수 없는 기본 생성자 JPA 리플렉션용 필수 ⭐️ 필수 (Protected)
@AllArgsConstructor 모든 필드를 받는 생성자 테스트 시 편리함 ❌ 비추천 (위험)
@RequiredArgsConstructor Final / @NonNull 필드만 의존성 주입(DI)용 🔺 세모 (Service/Controller용)

왜 엔티티에는 NoArgsConstructor를 사용할까?

  • 역할: 파라미터가 없는 public User() {} 같은 생성자를 만들어준다.
  • Entity에 필요한 이유: JPA는 DB 데이터를 객체에 매핑할 때 리플렉션(Reflection) 기술을 쓴다. 이때 기본 생성자가 없으면 객체를 생성할 수 없어 에러가 발생한다.
  • Best Practice: 무분별한 객체 생성을 막기 위해 access = AccessLevel.PROTECTED를 붙여 "JPA는 쓰게 해 줄게, 하지만 개발자가 직접 new 하는 건 안 돼"라고 선언하는 것이 정석이다. 그냥 public User() {}로 열어둬도 되지 않을까? 아니다. Public으로 열어두면 다른 개발자가 비즈니스 로직 어디선가 User user = new User();처럼빈 껍데기 객체를 만들 수 있게 된다. 이는 데이터 불완전성을 초래한다. 따라서 "JPA(프록시)는 접근할 수 있지만, 외부에서는 함부로 생성하지 못하게" 접근 권한을 PROTECTED로 막아두는 것이 가장 안전한 선택이다.

@AllArgsConstructor : 클래스에 있는 모든 필드를 한 번에 초기화하는 생성자를 만든다. 만약 개발자가 코드 수정 중 필드의 순서를 바꾼다면? 이 경우, 입력 타입이 둘 다 String이라서 컴파일 에러가 안 난다. 결과적으로 이메일 칸에 주소가 들어가고, 주소 칸에 이메일이 들어가는 끔찍한 데이터 오염이 발생한다. 따라서 Entity에서는 절대 사용하지 않는 것이 좋다. 대신 @Builder를 쓰자.

@RequiredArgsConstructor: final이 붙거나 @NonNull이 붙은 필드만 챙기는 생성자를 만든다. Entity보다는 Service나 Controller에서 주로 쓰인다. 스프링의 의존성 주입(DI) 방식 중 '생성자 주입'을 가장 깔끔하게 코딩할 수 있게 해주는 효자 어노테이션이다. 하지만 JPA Entity는 필드가 자주 변하고 기본 생성자가 필수라 이것만으로는 부족하다.


Enum의 딜레마: "DB Enum은 쓰지 마세요, 하지만 Java Enum은 쓰세요"

 

이전에 프로젝트를 진행하며 "DB에 Enum 타입을 만들었다가 수정 지옥을 맛본 경험"이 있다. 그래서 이번 프로젝트에서는 Enum의 장점만 취하고 단점은 버리는 전략을 선택했다.

1. 오해: 자바 Enum = DB Enum? (NO!)

우리가 흔히 겪는 불편함은 DB 레벨에서 CREATE TYPE role_enum AS ENUM ('USER', 'ADMIN');처럼 DB 자체 타입을 만들었을 때 발생한다.

  • 문제점: 나중에 'GUEST'를 추가하려면 DB 스키마를 변경(Alter Type) 해야 한다. 운영 중인 DB에서는 이게 매우 위험하고 번거로운 작업이다.

2. 해결책: 자바는 Enum, DB는 VARCHAR

Spring Data JPA는 이 문제를 아주 우아하게 해결해 준다. 자바 코드에서는 타입 안전성(Type Safety)이 보장되는 Enum을 쓰고, DB에 저장될 때는 @Enumerated(EnumType.STRING) 옵션을 통해 단순 문자열(VARCHAR)로 변환해서 저장하는 것이다.

3. 왜 이렇게 하는가? (실무에서 Enum을 쓰는 3가지 이유)

① 오타 방지와 컴파일 체크 (Type Safety)

  • String 사용 시: user.setRole("USRE");라고 오타를 내도 컴파일러는 모른다. 나중에 실행해봐야 에러가 터진다.
  • Enum 사용 시: user.setRole(UserRole.USRE);라고 쓰면 빨간 줄이 그어지며 컴파일 자체가 안 된다. 에러를 개발 단계에서 100% 잡을 수 있다.

② 리팩토링의 천국

  • 나중에 USER라는 이름을 MEMBER로 바꾸고 싶다면?
  • Enum을 쓰면 IDE의 '이름 변경(Rename)' 기능 한 방으로 프로젝트 전체의 모든 USER가 MEMBER로 바뀐다. 문자열로 하드코딩 되어 있었다면 "Find All" 해서 하나하나 고치다가 실수가 발생했을 것이다.

③ 로직의 응집도 (Group Logic)

  • 단순히 값만 갖는 게 아니라, 관련된 로직을 Enum 안에 묶을 수 있다.
    public enum UserRole {
        USER("일반 사용자", 1),
        ADMIN("관리자", 99);
    
        private final String description;
        private final int level;
    
        // "이 유저가 관리자 권한이 있나?" 같은 로직을 여기에 넣을 수 있음
        public boolean isAdmin() {
            return this == ADMIN;
        }
    }

"DB의 경직성은 피하고, 코드의 견고함은 챙긴다." 이것이 우리가 UserRole을 Enum으로 정의하고 @Enumerated(EnumType.STRING)을 붙인 이유다. DB 조회 툴에서 보면 그냥 VARCHAR로 보이기 때문에, 나중에 값을 추가하거나 수정할 때 DB 스키마를 건드릴 필요 없이 코드만 배포하면 된다.


JPA 연관관계 매핑: 1:1, 1:N, 그리고 N:M을 다루기

엔티티 설계의 꽃은 연관관계 매핑(Relationship Mapping)이다. User 엔티티 하나만 잘 만든다고 끝나는 게 아니다. 크리에이터 정보, 소셜 로그인 계정, 팔로우 관계 등 수많은 데이터가 User를 중심으로 얽혀 있다.

1. User와 Creator: 1:1 관계와 @MapsId의 미학

가장 많이 고민했던 부분이다. "모든 유저는 잠재적 크리에이터인데, 그냥 User 테이블에 계좌번호 컬럼을 넣으면 안 되나?" 하지만 90%의 유저는 크리에이터가 아니다. User 테이블에 크리에이터 전용 컬럼(계좌, 채널설명 등)을 넣으면 NULL 값이 가득한 테이블(Sparse Data)이 된다.

그래서 Creator 테이블을 분리하되, 두 테이블을 하나의 몸처럼(1:1) 묶기로 했다.

// Creator.java
@Entity
public class Creator {

    @Id
    private Long userId; // (1) PK를 따로 두지 않음

    @MapsId // (2) 핵심! User의 PK를 내 PK이자 FK로 쓴다
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    // ...
}
  • 전략: 식별 관계(Identifying Relationship) 패턴 적용.
  • 이유:
    1. 성능: Creator를 조회할 때 별도의 인덱스를 탈 필요 없이 User의 ID로 바로 찾을 수 있다.
    2. 정합성: User 없이는 Creator도 존재할 수 없음을 DB 레벨에서 강제한다.
    3. 공간 절약: 불필요한 creator_id 시퀀스를 만들지 않아도 된다.

2. User와 SocialAccount: 1:N과 생명주기 관리

한 명의 유저가 구글, 카카오 등 여러 소셜 계정을 가질 수 있다. 전형적인 1:N 관계다. 여기서 중요한 건 "유저가 탈퇴하면 소셜 정보는 어떻게 되는가?"이다.

// User.java
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<SocialAccount> socialAccounts = new ArrayList<>();
  • 전략: CascadeType.ALL과 orphanRemoval = true 사용.
  • 이유:
    • 생명주기 동기화: 유저(Parent)가 삭제되면, 연결된 소셜 계정(Child)들도 자동으로 DB에서 사라져야 한다.
    • 개발자가 일일이 socialAccountRepository.delete()를 호출하는 실수를 방지하고, User 객체 하나만 지우면 깔끔하게 정리되도록 설계했다.
더보기

고아 객체 제거 (orphanRemoval = true)

  • 상황: 부모(User)가 자식(SocialAccount) 리스트에서 특정 자식을 제외시켰을 때.
    user.getSocialAccounts().remove(0); // 리스트에서 첫 번째 계정 삭제
    
  • 동작:
    • orphanRemoval = true: 리스트에서 빠진 순간, DB에서도 해당 데이터(DELETE)가 날아간다. (진짜 고아처럼 취급되어 사라짐)
    • false (기본값): 리스트에서만 빠지고 DB에는 그대로 남는다. (연관관계만 끊김)
  • 주의: 자식이 오직 이 부모에게만 종속될 때 써야 한다. (다른 곳에서도 참조하는데 지워버리면 대참사 발생)

3. Follow: N:M을 1:N으로 풀어내기 (Self-Referencing)

"유저가 유저를 팔로우한다." 나(User)도 유저고, 내가 팔로우하는 대상(User)도 유저다. 전형적인 다대다(N:M) 관계다. JPA의 @ManyToMany를 쓰면 편해 보이지만, 실무에서는 절대 쓰지 않는다.

  • Why? @ManyToMany는 중간 테이블을 자동으로 만들어주지만, 그 테이블에 '팔로우한 시간(created_at)' 같은 추가 정보를 넣을 수가 없다.

그래서 Follow라는 중간 엔티티를 직접 만들어서 1:N, N:1로 풀었다.

// Follow.java
@Entity
public class Follow extends BaseTimeEntity { // BaseTimeEntity로 '언제 팔로우했는지' 기록

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "follower_id")
    private User follower; // 팬

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "creator_id")
    private User creator; // 크리에이터
}
  • 전략: 연결 테이블 승격 (Link Entity).
  • 이점:
    • 단순한 연결을 넘어, "2024년 1월 1일에 팔로우함" 같은 이력(History) 관리가 가능하다.
    • 나중에 "팔로우 알림 설정 여부" 같은 컬럼이 추가되어도 유연하게 대처할 수 있다.

관계를 맺을 때마다 "이게 OneToMany였나?", "여기에 mappedBy를 써야 하나?" 헷갈린다면 이 기준만 기억하자.

더보기

1. 관계의 주인은 누구인가? (@JoinColumn vs mappedBy)

JPA에서 양방향 관계를 맺을 때 가장 중요한 개념이다.

  • 주인(Owner): 실제 DB 테이블에 외래키(FK)를 가지고 있는 쪽.
    • @JoinColumn을 사용한다.
    • 주인이 값을 변경해야 DB에 반영된다.
  • 하인(Inverse): 외래키가 없는 쪽. (그냥 거울처럼 비춰보기만 하는 쪽)
    • mappedBy를 사용한다. ("나는 저쪽(주인)에 의해 매핑되었다"는 뜻)
    • 여기서 list.add()를 아무리 해도 DB에는 아무 일도 안 일어난다. (단순 조회용)
  • 예시
    • 1:N 관계에서 'N'쪽(Many)이 무조건 주인이다. (FK가 거기 있으니까)
    • 1:1 관계에서는 주인을 내가 정할 수 있다. (보통 FK를 가진 쪽을 주인으로 함)

2. 어떤 관계를 써야 할까? (@ManyToOne vs @OneToOne)

  • @ManyToOne (N:1): 가장 많이 쓴다.
    • 상황: "나(게시글) 하나에 댓글(User) 여러 개가 달릴 수 있다." -> 댓글 입장에서 나는 게시글에 종속됨.
    • 위치: FK를 가진 쪽(댓글)에 붙인다.
  • @OneToOne (1:1): 너랑 나랑 딱 하나씩만.
    • 상황: "유저(User) 한 명당 크리에이터 정보(Creator) 하나."
    • 특징: 유니크(Unique) 조건이 걸린 FK라고 보면 된다.

4. 마무리 및 다음 계획

단순히 "돌아가는 코드"가 아니라, "왜 이렇게 짰는지 설명할 수 있는 코드"를 짜려고 노력했다. JPA의 어노테이션 하나하나가 DB 성능과 데이터 안전성에 큰 영향을 미친다는 것을 다시 한번 확인했다.

이제 뼈대(Entity)는 완성되었으니, 다음 포스팅에서는 이 뼈대에 살을 붙이는 Repository 계층 구현실제 데이터를 넣어서 테스트하는 과정을 담아보려 한다.