개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (2) - 도메인 설계와 JPA Entity의 기술적 고민들

baby-t 2025. 11. 24. 11:46

1. 들어가며: 설계의 시작

지난 포스팅에서 Docker와 Spring Boot 환경 설정을 마쳤습니다. 이번에는 실제 주식 거래소의 핵심이 되는 **회원(User)**과 계좌(Account) 데이터를 어떻게 다룰지 설계하고, JPA를 통해 구현한 과정을 정리합니다.

단순히 "코드를 짰다"가 아니라, **"왜 이 어노테이션을 썼는지", "왜 이런 구조를 선택했는지"**에 대한 기술적 고민을 중점적으로 기록합니다.


2. 데이터베이스 설계 (ERD)

주식 거래소 시스템의 데이터 모델링 핵심은 **회원(User)**과 **자산(Account)**의 분리입니다.

  • User: 사용자의 고유 식별 정보 (이메일, 이름 등)
  • Account: 변동이 잦은 자산 정보 (잔고)
  • 관계: 1 대 1 (One-to-One)

[설계 의도] User 테이블에 balance 필드를 두지 않고 Account 테이블로 분리한 이유는 데이터의 성격이 다르기 때문입니다. 회원 정보는 잘 변하지 않지만, 잔고는 초당 수십 번 변할 수 있습니다. 이를 분리함으로써 추후 자산 데이터에만 집중적으로 **Lock(동시성 제어)**을 걸 때 성능 저하를 최소화할 수 있습니다.


3. JPA Entity 구현

설계한 내용을 실제 Java 코드로 구현하며 했던 기술적 고민들을 정리해 봅니다.

3-1. User 엔티티 구현

Java
 
@Entity
@Table(name = "users") // (Q1)
@Getter // (Q2)
@NoArgsConstructor(access = AccessLevel.PROTECTED) // (Q3)
public class User {

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

    @Column(unique = true, nullable = false) // (Q5)
    private String email;
    
    // ... 생략 ...
    
    // 생성자에는 ID를 넣지 않음 (Q4)
    public User(String email, String password, String name) {
        this.email = email;
        this.password = password;
        this.name = name;
    }
}

💡 기술적 회고 (Technical Review)

Q1. 왜 User에만 @Table을 붙였는가? MySQL 등 대부분의 RDBMS에서 USER는 시스템 예약어입니다. @Table(name = "users")를 명시하지 않으면 쿼리 실행 시 문법 에러가 발생할 수 있어 명시적으로 테이블 이름을 지정했습니다. (Account는 예약어가 아니므로 생략 가능하여 기본값을 따릅니다.)

Q2. 롬복(Lombok)의 @Getter 동작 시점은? 어노테이션을 붙이면 **컴파일 시점(Compile-time)**에 자동으로 getEmail() 등의 메서드가 생성됩니다. 중요한 점은 @Getter만 붙였으므로 Setter는 생성되지 않는다는 점입니다. 무분별한 Setter 사용을 막아 객체의 일관성을 유지했습니다.

Q3. 왜 기본 생성자(@NoArgsConstructor)가 필수인가? JPA는 DB 데이터를 객체로 매핑할 때 Java Reflection 기술을 사용합니다. 리플렉션은 객체를 생성할 때 파라미터 없는 기본 생성자를 필요로 하기 때문에, 이것이 없으면 에러가 발생합니다.

Q4. @GeneratedValue와 생성자 GenerationType.IDENTITY 전략은 ID 생성을 전적으로 **DB(Auto Increment)**에 위임합니다. 즉, 자바 객체를 생성하는 시점(new User(...))에는 ID가 없고, repository.save()를 호출하는 순간 DB가 ID를 부여합니다. 따라서 생성자 파라미터에서 ID는 제외했습니다.

Q5. @Column은 언제 붙이는가? 필드(멤버 변수)에 아무것도 안 붙여도 DB 컬럼은 생성됩니다. 하지만 email처럼 **중복 불가(unique)**나 필수 입력(not null) 같은 제약조건이 필요한 경우 @Column을 명시하여 데이터 무결성을 보장했습니다. 반면 Account의 필드들은 특별한 제약조건이 없어 어노테이션을 생략하여 코드를 간결하게 유지했습니다.


3-2. Account 엔티티 구현

Java
 
@Entity
@Getter
@NoArgsConstructor
public class Account {
    // ... 생략 ...

    @OneToOne(fetch = FetchType.LAZY) // (Q6, Q7)
    @JoinColumn(name = "user_id")
    private User user;
    
    // ... 생략 ...
}

💡 기술적 회고 (Technical Review)

Q6. 왜 User는 Account를 모르게 설계했는가? (단방향 매핑) Account에는 user 필드가 있어 주인을 찾을 수 있지만, User에는 account 필드를 만들지 않았습니다. 로그인 등 회원 정보를 조회할 때마다 불필요하게 계좌 정보를 신경 쓸 필요가 없기 때문입니다. 필요하다면 AccountRepository를 통해 찾으면 되므로, 객체를 가볍게 유지하기 위해 단방향 관계를 선택했습니다.

Q7. FetchType.LAZY란 무엇인가? '지연 로딩' 전략입니다. 계좌(Account)를 조회한다고 해서 무조건 회원(User) 정보가 필요하진 않습니다. 만약 EAGER(즉시 로딩)로 설정하면 계좌를 조회할 때마다 회원 테이블까지 조인(Join)하여 성능이 떨어집니다. LAZY를 사용하여 실제로 getUser()를 호출하는 시점에 쿼리가 나가도록 최적화했습니다.


4. Repository 계층과 Spring Data JPA

Java
 
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

[선택의 이유: 왜 MyBatis가 아닌 JPA인가?] 금융권 등 레거시 시스템에서는 SQL을 직접 다루는 MyBatis를 많이 쓰지만, 이번 프로젝트는 객체 지향적인 설계생산성을 위해 JPA를 선택했습니다.

  • 생산성: 인터페이스만 정의하면 save, findById 같은 CRUD가 자동 생성됩니다.
  • 쿼리 메서드: findByEmail처럼 메서드 이름만 규칙에 맞춰 지으면, 프레임워크가 알아서 SELECT * FROM users WHERE email = ? SQL을 생성해 줍니다.

5. 통합 테스트 (Integration Test)

설계한 도메인과 리포지토리가 실제 DB(Docker MySQL)와 잘 연동되는지 확인하기 위해 테스트 코드를 작성했습니다.

Java
@SpringBootTest  // 1. 스프링 컨테이너를 띄워서 실제 환경처럼 테스트하겠다.
@Transactional   // 2. 테스트가 끝나면 데이터를 모두 롤백(삭제)해라. (DB 오염 방지)
class DomainTest {

    @Autowired UserRepository userRepository;       // 창고지기 1
    @Autowired AccountRepository accountRepository; // 창고지기 2

    @Test
    @DisplayName("회원가입을 하고 계좌를 생성하면 DB에 잘 들어가야 한다")
    void userAndAccountTest() {
        // --- 1. 회원가입 (User 저장) ---
        // Given: 유저 정보를 만들고
        User user = new User("gemini@test.com", "1234", "제미나이");
        
        // When: DB에 저장하면
        User savedUser = userRepository.save(user);

        // Then: ID가 생성되어야 하고, 입력한 정보와 같아야 한다.
        assertThat(savedUser.getId()).isNotNull(); 
        assertThat(savedUser.getEmail()).isEqualTo("gemini@test.com");


        // --- 2. 계좌 개설 (User와 연결) ---
        // Given: 위에서 만든 유저의 계좌를 만들고
        Account account = new Account(savedUser);
        
        // When: DB에 저장하면
        accountRepository.save(account);

        // Then: 계좌 ID가 생겨야 하고, 잔액은 0원이며, 주인이 맞아야 한다.
        assertThat(account.getId()).isNotNull();
        assertThat(account.getBalance()).isEqualTo(0L);
        assertThat(account.getUser().getName()).isEqualTo("제미나이"); // User와 연결 확인!

        System.out.println(">>> 테스트 성공! 생성된 User ID: " + savedUser.getId());
    }
}

 

[테스트 결과] @Transactional 어노테이션 덕분에 테스트가 끝난 후 DB에 쓰레기 데이터가 남지 않고 깔끔하게 롤백되는 것을 확인했습니다.

 


6. 마치며

이번 포스팅에서는 가상 주식 거래소의 뼈대가 되는 도메인을 설계하고, JPA를 통해 영속성 계층을 구현했습니다. 특히 단순한 구현을 넘어, JPA의 동작 원리(기본 생성자, 지연 로딩 등)를 이해하고 적용하는 데 집중했습니다.

다음 포스팅에서는 이 도메인들을 활용해 실제 비즈니스 로직(회원가입, 로그인, 계좌 생성)을 수행하는 Service 계층을 구현해 보겠습니다.