JAVA

[2단계] CRUD API 구현과 계층형 아키텍처 (SRP)

승운노트 2025. 11. 14. 16:45

핵심 OOP 원칙

SRP (단일 책임 원칙). 각 계층은 오직 자신의 책임에만 집중합니다.

계층형 아키텍처: 왜 역할을 나눠야 할까?

애플리케이션의 기능을 구현할 때, 각 코드 조각에 명확한 역할을 부여하면 훨씬 더 관리하기 좋은 구조를 만들 수 있습니다.
우리는 코드를 세 개의 계층으로 분리하여 각자의 책임에만 집중하도록 할 것입니다.

  • Controller (프레젠테이션 계층): "외부의 요청을 받고, 응답을 보내는" 최전방 창구 역할.
  • Service (비즈니스 계층): "핵심 비즈니스 로직을 처리하는" 실무 전문가 역할.
  • Repository (데이터 접근 계층): "데이터베이스와 통신하는" 창고 관리자 역할.

 

  • 공통 DTO 설계API를 구현하기 전에, 각 계층이 주고받을 데이터의 형식을 먼저 정의합니다.
  • 이를 DTO(Data Transfer Object)라고 합니다.

 

 

package com.puzzlix.solid_task._global.dto;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 모든 API 응답을 감싸는 공통 DTO 설계
 * @param <T>
 */
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CommonResponseDto<T> {
    private boolean success;
    private T data;
    private String message;

    //    private CommonResponseDto(boolean success, T data, String message) {
    //        this.success = success;
    //        this.data = data;
    //        this.message = message;
    //    }
    // 대체
    // @AllArgsConstructor(access = AccessLevel.PRIVATE)

    // 정적 팩토리 메서드 (팩토리 패턴과 다름 개념)
    // static 객체 속성이 아니라 클래스에 포함 - ClassName.add();
    public static <T> CommonResponseDto<T> success(T data, String message) {
        return new CommonResponseDto<>(true, data, message);
    }

    public static <T> CommonResponseDto<T> success(T data) {
        return success(data,null);
    }

    // 실패 응답을 생성하는 정적 팩토리 메서드
    public static <T> CommonResponseDto<T> error(String message) {
        return new CommonResponseDto<>(false, null, message);
    }

    /**
     * 클라이언트 코드(Controller)로 부터 객체 생성 과정을 완전히 분리하고 숨기는 것이 목표다.
     * 이는 주로 OCP(개방-폐쇄 원칙)을 만족시키는 코드이다.
     */
    // new CommonResponseDto(); <- private 생성자
    // CommonResponseDto.error("잘못된 비번입니다");
}

 

 

  • IssueRequest
package com.puzzlix.solid_task.domain.issue.dto;

import lombok.Getter;
import lombok.Setter;

public class IssueRequest {

    // 모바일에서 또는 클라언트에서 - 이슈 생성 요청
    @Getter
    @Setter
    public static class Create {
        // 클라이언트가 직접 입력해야 하는 정보 또는 셋팅 되어야 하는 정보
        private String title;
        private String description;
        private Long projectId;
        private Long reporterId;
    } // end of static inner class

    // IssueRequest.Create dto = new IssueRequest.Create(...);
}

 

package com.puzzlix.solid_task.domain.issue.dto;

import com.puzzlix.solid_task.domain.issue.Issue;
import com.puzzlix.solid_task.domain.issue.IssueStatus;

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

public class IssueResponse {

    public static class FindAll {
        private final Long id;
        private final String title;
        private final IssueStatus status;

        // 생성자를 private 선언
        private FindAll(Issue issue) {
            this.id = issue.getId();
            this.title = issue.getTittle();
            this.status = issue.getIssueStatus();
        }

        // 정적 팩토리 메서드 선언 (이녀석은 제네릭이 아님)
        // IssueResponse.from([issue, issue, issue]);
        // Entity 를 DTO 로 변환하는 정적 팩토리 메서드를 만듬.
        public static List<FindAll> from(List<Issue> issues) {
            List<FindAll> dtoList = new ArrayList<>();
            for (Issue issue : issues) {
                dtoList.add(new FindAll(issue));
            }
            return  dtoList;
        }
    }

}

 

 

package com.puzzlix.solid_task.domain.issue;

import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Repository // 이 클래스가 데이터 저장소 역할을 하는 스프링 빈임을 선언 함
public class MemoryIssueRepository implements IssueRepository {

    // 동시성 문제를 방지하기 위해 ConcurrentHashMap 사용
    private static Map<Long, Issue> store = new ConcurrentHashMap<>();
    private static AtomicLong sequence = new AtomicLong(0);

    @Override
    public Issue save(Issue issue) {
        // save 요청시 Issue 에 상태값 id가 없는 상태이다.
        if(issue.getId() == null) {
            // -> 1 변경 하고 issue 객체에 상태값 id를 1로 할당
            issue.setId(sequence.incrementAndGet());
            // sequence -> 1  로 결정됨
            // sequence -> 2  로 결정됨
        }
        store.put(issue.getId(), issue);
        return issue;
    }

    @Override
    public Optional<Issue> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<Issue> findAll() {
        return new ArrayList<>(store.values());
    }
}

 

 

서비스단 생성

 

package com.puzzlix.solid_task.domain.issue;

import com.puzzlix.solid_task.domain.issue.dto.IssueRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service // IoC
@RequiredArgsConstructor
public class IssueService {

    // 구체 클래스가 아닌, IssueRepository 라는 역할(인터페이스)에만 의존한다.
    private final IssueRepository issueRepository;

    // DI 처리 함
//    public IssueService(IssueRepository issueRepository) {
//        this.issueRepository = issueRepository;
//    }

    // 이슈 생성 로직
    public Issue createIssue(IssueRequest.Create request) {
        Issue newIssue = new Issue();
        newIssue.setTittle(request.getTitle());
        newIssue.setDescription(request.getDescription());
        newIssue.setReporterId(request.getReporterId());
        // 이슈 --> TODO
        newIssue.setIssueStatus(IssueStatus.TODO);

        return issueRepository.save(newIssue);
    }

    // 모든 이슈 조회
    public List<Issue> findIssues() {
        return issueRepository.findAll();
    }

}

 

 

컨트롤러 생성

 

package com.puzzlix.solid_task.domain.issue;

import com.puzzlix.solid_task._global.dto.CommonResponseDto;
import com.puzzlix.solid_task.domain.issue.dto.IssueRequest;
import com.puzzlix.solid_task.domain.issue.dto.IssueResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/issues")
@RequiredArgsConstructor
public class IssueController {

    private final IssueService issueService;

    /**
     * 이슈 생성 API
     * POST /api/issues
     */
    @PostMapping
    public ResponseEntity<CommonResponseDto<Issue>> createIssue(@RequestBody IssueRequest.Create request) {

        Issue createdIssue = issueService.createIssue(request);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(CommonResponseDto.success(createdIssue));
    }

    /**
     * 이슈 목록 조회 API
     * GET /api/issues
     */
    @GetMapping
    public ResponseEntity<CommonResponseDto<List<IssueResponse.FindAll>>> getIssues() {
        // 서비스에서 조회 요청
        List<Issue> issues = issueService.findIssues();
        // 조회된 도메인 이슈 리스트를 DTO로 변환
        List<IssueResponse.FindAll> responseDtos = IssueResponse.FindAll.from(issues);
        return ResponseEntity.ok(CommonResponseDto.success(responseDtos));
    }
}

 

 

Controller, Service, Repository라는 세 개의 계층으로 역할을 완벽하게 분리하여 SRP를 준수하는 코드를 작성했습니다.
다음 단계 에서는 이 유연한 구조가 얼마나 강력한지, 메모리 저장소를 실제 데이터베이스로 바꾸는 과정을 통해서
장점들을 확인해 보자.