· 개인

실무에서 자주 사용하는 데이터베이스 & API 패턴 완전 정리

마스터-디테일, 다대다 해소, 이력 관리, 소프트 삭제 같은 테이블 패턴과 페이징·배치·비동기 API 패턴을 예제 SQL과 함께 모아, 어떤 문제에 어떤 패턴을 고를지 기준을 세운 정리.

작성날짜: 25.07.19

중요한 것은 무작정 복잡한 패턴을 사용하는 것이 아니라, 현재 해결하고자 하는 문제에 적합한 패턴을 선택하는 것

📊 데이터베이스 테이블 패턴

1. Master-Detail 패턴

[!NOTE] 회사와 직원, 주문과 주문상세처럼 1:N 관계를 표현하는 가장 기본적인 패턴입니다.

-- 주문 마스터 테이블
CREATE TABLE orders (
    order_id INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT NOT NULL,
    order_date DATE NOT NULL,
    total_amount DECIMAL(10,2)
);

-- 주문 상세 테이블
CREATE TABLE order_details (
    detail_id INT PRIMARY KEY AUTO_INCREMENT,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
  • 사용 사례
    • 주문 시스템 (주문 ↔ 주문상세)
    • 게시판 (게시글 ↔ 댓글)
    • 송장 관리 (송장 ↔ 송장상세)

2. Many-to-Many 해결 패턴

[!NOTE] 학생과 수업처럼 다대다 관계를 연결 테이블로 해결하는 패턴입니다.

CREATE TABLE students (
    student_id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE courses (
    course_id INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL
);

-- 연결 테이블 (Junction Table)
CREATE TABLE enrollments (
    enrollment_id INT PRIMARY KEY AUTO_INCREMENT,
    student_id INT NOT NULL,
    course_id INT NOT NULL,
    grade INT,
    enrollment_date DATE DEFAULT CURRENT_DATE,
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (course_id) REFERENCES courses(course_id),
    UNIQUE KEY unique_enrollment (student_id, course_id)
);
  • 사용 사례
    • 사용자 ↔ 권한 관리
    • 상품 ↔ 카테고리
    • 태그 시스템

3. 계층구조 패턴

[!NOTE] 조직도나 댓글의 대댓글처럼 자기 참조 관계를 표현하는 패턴입니다.

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    parent_emp_id INT NULL,
    position VARCHAR(50),
    FOREIGN KEY (parent_emp_id) REFERENCES employees(emp_id)
);

-- 데이터 예시
INSERT INTO employees VALUES 
(1, '김사장', NULL, 'CEO'),
(2, '이부장', 1, '부장'),
(3, '박과장', 2, '과장'),
(4, '최사원', 3, '사원');

4. 이력관리 패턴

[!NOTE] 금융이나 중요한 비즈니스 데이터에서 모든 변경사항을 추적하는 패턴입니다.

CREATE TABLE users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(200) NOT NULL
);

-- 이력 테이블 (절대 DELETE 금지!)
CREATE TABLE user_history (
    history_id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    changed_date DATETIME DEFAULT CURRENT_TIMESTAMP,
    field_name VARCHAR(50) NOT NULL,
    old_value TEXT,
    new_value TEXT,
    changed_by INT NOT NULL,
    action_type ENUM('INSERT', 'UPDATE', 'DELETE') NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

5. 소프트 삭제 패턴

[!NOTE] 데이터를 물리적으로 삭제하지 않고 논리적으로 삭제하는 패턴입니다.

CREATE TABLE users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(200) NOT NULL,
    deleted_at DATETIME NULL,
    is_deleted BOOLEAN DEFAULT FALSE,
    INDEX idx_not_deleted (is_deleted, deleted_at)
);

-- 활성 사용자만 조회
SELECT * FROM users WHERE is_deleted = FALSE;

-- 소프트 삭제 실행
UPDATE users 
SET is_deleted = TRUE, deleted_at = NOW() 
WHERE user_id = 123;

🚀 API 설계 패턴

1. RESTful 패턴

[!NOTE] HTTP 메서드를 의미있게 사용하는 표준 REST API 패턴입니다.

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping                    // GET /api/users
    public List<User> getUsers() { /* 목록 조회 */ }
    
    @GetMapping("/{id}")          // GET /api/users/123
    public User getUser(@PathVariable Long id) { /* 단건 조회 */ }
    
    @PostMapping                  // POST /api/users
    public User createUser(@RequestBody User user) { /* 생성 */ }
    
    @PutMapping("/{id}")         // PUT /api/users/123
    public User updateUser(@PathVariable Long id, @RequestBody User user) { /* 전체 수정 */ }
    
    @PatchMapping("/{id}")       // PATCH /api/users/123
    public User patchUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) { /* 부분 수정 */ }
    
    @DeleteMapping("/{id}")      // DELETE /api/users/123
    public void deleteUser(@PathVariable Long id) { /* 삭제 */ }
}

2. 배치 처리 패턴

[!NOTE] 여러 작업을 한 번에 처리하여 성능을 최적화하는 패턴입니다.

@PostMapping("/api/orders/batch")
public ResponseEntity<BatchResponse> processBatchOrders(@RequestBody BatchOrderRequest request) {
    
    BatchResponse response = new BatchResponse();
    
    try {
        // 트랜잭션으로 묶어서 처리
        transactionTemplate.execute(status -> {
            for (OrderRequest orderReq : request.getOrders()) {
                try {
                    Order order = orderService.createOrder(orderReq);
                    response.addSuccess(order.getId());
                } catch (Exception e) {
                    response.addError(orderReq.getCustomerId(), e.getMessage());
                }
            }
            return null;
        });
        
        return ResponseEntity.ok(response);
        
    } catch (Exception e) {
        return ResponseEntity.status(500).body(response);
    }
}

@Data
public class BatchOrderRequest {
    private List<OrderRequest> orders;
}

@Data
public class BatchResponse {
    private List<Long> successIds = new ArrayList<>();
    private List<ErrorDetail> errors = new ArrayList<>();
    
    public void addSuccess(Long orderId) {
        successIds.add(orderId);
    }
    
    public void addError(Long customerId, String message) {
        errors.add(new ErrorDetail(customerId, message));
    }
}

3. 페이징 패턴

[!NOTE] 대용량 데이터를 효율적으로 조회하는 패턴입니다.

@GetMapping("/api/products")
public ResponseEntity<PageResponse<Product>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "id") String sortBy,
        @RequestParam(defaultValue = "asc") String sortDir) {
    
    // Spring Data JPA 페이징
    Sort sort = Sort.by(Sort.Direction.fromString(sortDir), sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Product> productPage = productRepository.findAll(pageable);
    
    PageResponse<Product> response = PageResponse.<Product>builder()
        .content(productPage.getContent())
        .page(page)
        .size(size)
        .totalElements(productPage.getTotalElements())
        .totalPages(productPage.getTotalPages())
        .first(productPage.isFirst())
        .last(productPage.isLast())
        .build();
    
    return ResponseEntity.ok(response);
}

// 커서 기반 페이징 (대용량 데이터에 효율적)
@GetMapping("/api/products/cursor")
public ResponseEntity<CursorResponse<Product>> getProductsByCursor(
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") int limit) {
    
    List<Product> products;
    if (cursor == null) {
        products = productRepository.findTopNOrderById(limit + 1);
    } else {
        Long cursorId = Long.parseLong(new String(Base64.getDecoder().decode(cursor)));
        products = productRepository.findTopNAfterIdOrderById(cursorId, limit + 1);
    }
    
    boolean hasNext = products.size() > limit;
    if (hasNext) {
        products = products.subList(0, limit);
    }
    
    String nextCursor = null;
    if (hasNext && !products.isEmpty()) {
        Long lastId = products.get(products.size() - 1).getId();
        nextCursor = Base64.getEncoder().encodeToString(lastId.toString().getBytes());
    }
    
    CursorResponse<Product> response = CursorResponse.<Product>builder()
        .data(products)
        .nextCursor(nextCursor)
        .hasNext(hasNext)
        .build();
    
    return ResponseEntity.ok(response);
}

4. 상태 관리 패턴

[!NOTE] 주문이나 승인 프로세스의 상태 전이를 관리하는 패턴입니다.

public enum OrderStatus {
    PENDING("대기중"),
    PAID("결제완료"), 
    SHIPPED("배송중"),
    DELIVERED("배송완료"),
    CANCELLED("취소됨");
    
    private final String description;
    
    OrderStatus(String description) {
        this.description = description;
    }
}

@Service
public class OrderStateMachine {
    
    private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_TRANSITIONS = Map.of(
        OrderStatus.PENDING, Set.of(OrderStatus.PAID, OrderStatus.CANCELLED),
        OrderStatus.PAID, Set.of(OrderStatus.SHIPPED, OrderStatus.CANCELLED),
        OrderStatus.SHIPPED, Set.of(OrderStatus.DELIVERED),
        OrderStatus.DELIVERED, Set.of(),
        OrderStatus.CANCELLED, Set.of()
    );
    
    public void changeOrderStatus(Long orderId, OrderStatus newStatus) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        OrderStatus currentStatus = order.getStatus();
        
        if (!isTransitionAllowed(currentStatus, newStatus)) {
            throw new IllegalStateTransitionException(
                String.format("%s에서 %s로 상태 변경이 불가능합니다.", 
                currentStatus.getDescription(), newStatus.getDescription()));
        }
        
        order.setStatus(newStatus);
        order.setStatusChangedAt(LocalDateTime.now());
        orderRepository.save(order);
        
        // 상태 변경 이벤트 발행
        eventPublisher.publishEvent(new OrderStatusChangedEvent(orderId, currentStatus, newStatus));
    }
    
    private boolean isTransitionAllowed(OrderStatus from, OrderStatus to) {
        return ALLOWED_TRANSITIONS.get(from).contains(to);
    }
}

@RestController
public class OrderStatusController {
    
    @PostMapping("/api/orders/{orderId}/status")
    public ResponseEntity<String> changeOrderStatus(
            @PathVariable Long orderId,
            @RequestBody ChangeStatusRequest request) {
        
        try {
            orderStateMachine.changeOrderStatus(orderId, request.getNewStatus());
            return ResponseEntity.ok("주문 상태가 변경되었습니다.");
        } catch (IllegalStateTransitionException e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
}

5. 비동기 처리 패턴

[!NOTE] 시간이 오래 걸리는 작업을 백그라운드에서 처리하는 패턴입니다.

@RestController
public class AsyncJobController {
    
    @Autowired
    private JobService jobService;
    
    @PostMapping("/api/reports/generate")
    public ResponseEntity<JobResponse> generateReport(@RequestBody ReportRequest request) {
        
        // 비동기 작업 시작
        String jobId = jobService.startReportGeneration(request);
        
        JobResponse response = JobResponse.builder()
            .jobId(jobId)
            .status(JobStatus.PROCESSING)
            .message("리포트 생성이 시작되었습니다.")
            .build();
        
        return ResponseEntity.accepted().body(response);
    }
    
    @GetMapping("/api/jobs/{jobId}")
    public ResponseEntity<JobResponse> getJobStatus(@PathVariable String jobId) {
        
        Job job = jobService.getJob(jobId);
        
        JobResponse response = JobResponse.builder()
            .jobId(jobId)
            .status(job.getStatus())
            .progress(job.getProgress())
            .message(job.getMessage())
            .resultUrl(job.getResultUrl())
            .build();
        
        return ResponseEntity.ok(response);
    }
}

@Service
public class JobService {
    
    private final Map<String, Job> jobStore = new ConcurrentHashMap<>();
    
    @Async
    public String startReportGeneration(ReportRequest request) {
        String jobId = UUID.randomUUID().toString();
        
        Job job = Job.builder()
            .id(jobId)
            .status(JobStatus.PROCESSING)
            .progress(0)
            .startTime(LocalDateTime.now())
            .build();
        
        jobStore.put(jobId, job);
        
        // 실제 작업 실행
        CompletableFuture.runAsync(() -> {
            try {
                processReport(job, request);
            } catch (Exception e) {
                job.setStatus(JobStatus.FAILED);
                job.setMessage("리포트 생성 실패: " + e.getMessage());
            }
        });
        
        return jobId;
    }
    
    private void processReport(Job job, ReportRequest request) {
        try {
            // 단계별 처리
            updateProgress(job, 25, "데이터 수집 중...");
            Thread.sleep(2000); // 실제 작업 시뮬레이션
            
            updateProgress(job, 50, "데이터 분석 중...");
            Thread.sleep(3000);
            
            updateProgress(job, 75, "리포트 생성 중...");
            Thread.sleep(2000);
            
            updateProgress(job, 100, "완료");
            job.setStatus(JobStatus.COMPLETED);
            job.setResultUrl("/files/reports/" + job.getId() + ".pdf");
            job.setEndTime(LocalDateTime.now());
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            job.setStatus(JobStatus.FAILED);
            job.setMessage("작업이 중단되었습니다.");
        }
    }
    
    private void updateProgress(Job job, int progress, String message) {
        job.setProgress(progress);
        job.setMessage(message);
        job.setUpdatedAt(LocalDateTime.now());
    }
}