Backend

Backend API와 데이터 시스템 설계 기초

Spring 회원 관리 예제, MVC, DB 접근, SQL, NoSQL, 캐시를 백엔드 시스템 설계 흐름으로 정리합니다.

Backend API와 데이터 시스템 설계 기초

백엔드 API를 만들 때 처음에는 Controller와 URL, JSON 응답에 눈이 간다. 하지만 조금만 더 진행하면 API 코드는 데이터 모델, 트랜잭션, 검증, 예외 처리, 테스트, 로그, 배포 환경과 바로 연결된다. 단순 CRUD라도 계층을 어떻게 나누는지, 저장소를 어떻게 바꿀 수 있게 만드는지, 실패를 어디에서 다루는지에 따라 유지보수 난이도가 달라진다.

Spring 회원 관리 예제는 기능만 보면 작다. 회원 이름을 받아 저장하고, 목록을 조회한다. 그런데 이 작은 예제 안에도 백엔드 설계의 기본 질문이 들어 있다. 도메인 객체는 어디에 둘 것인가, 비즈니스 규칙은 어디에서 검증할 것인가, Repository 구현체가 메모리에서 RDB로 바뀌어도 Service 코드는 유지될 수 있는가, 테스트는 서로 영향을 주지 않는가.

API는 URL 목록이 아니라 계약이다

REST API를 처음 만들 때는 GET, POST, PUT, DELETE와 URL을 맞추는 데 집중하기 쉽다. 하지만 운영되는 API는 단순한 URL 목록이 아니라 클라이언트와 서버 사이의 계약이다. 어떤 요청을 허용하는지, 성공과 실패를 어떤 상태 코드로 표현하는지, 에러 응답이 어떤 구조인지, 페이지네이션과 정렬은 어떻게 동작하는지까지 포함한다.

회원 목록을 조회하는 API를 예로 들면 단순히 배열을 반환하는 것보다, 페이지 정보와 필터 기준을 함께 표현하는 편이 나중에 화면과 배치 처리에서 다루기 쉽다.

GET /api/members?page=1&size=20&status=ACTIVE
Accept: application/json
{
  "items": [
    {
      "id": 1,
      "name": "Kim Taeyoung",
      "status": "ACTIVE",
      "createdAt": "2025-12-29T10:00:00+09:00"
    }
  ],
  "page": 1,
  "size": 20,
  "totalCount": 132
}

생성 API는 요청의 유효성 검증과 중복 정책을 분명히 해야 한다.

POST /api/members
Content-Type: application/json
{
  "name": "Kim Taeyoung",
  "email": "taeyoung@example.com"
}

성공 시에는 생성된 리소스의 식별자를 돌려준다.

201 Created
Location: /api/members/1
{
  "id": 1,
  "name": "Kim Taeyoung",
  "email": "taeyoung@example.com",
  "status": "ACTIVE"
}

실패 응답도 계약이다. 화면이 어떤 필드를 어떻게 고쳐야 하는지 알려면 에러 구조가 안정적이어야 한다.

{
  "code": "VALIDATION_ERROR",
  "message": "Request validation failed",
  "fieldErrors": {
    "email": "Email format is invalid"
  },
  "traceId": "req-20251229-001"
}

traceId는 사용자에게 원인을 설명하는 값은 아니지만, 운영자가 로그에서 같은 요청을 찾는 단서가 된다. API 계약을 이렇게 잡아두면 프론트엔드, 백엔드, QA, 운영자가 같은 실패를 같은 언어로 볼 수 있다.

작은 요구사항에서 계층을 나누기

회원 관리 예제의 요구사항은 단순하다.

Data
- member id
- name

Feature
- register member
- list members

Initial assumption
- storage is not decided yet
- start with in-memory repository

여기서 바로 DB 테이블부터 만들 수도 있지만, 먼저 계층을 나눠두면 저장소를 바꾸는 실험이 쉬워진다.

Controller  HTTP 요청과 응답 처리
Service     핵심 비즈니스 규칙
Repository  데이터 접근
Domain      비즈니스 객체

회원 도메인은 단순한 객체로 시작한다.

package hello.hellospring.domain;

public class Member {
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Repository는 인터페이스로 둔다. 구현체가 메모리인지, JDBC인지, JPA인지 Service가 직접 알 필요 없게 하기 위해서다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

처음에는 메모리 구현체로 충분하다.

public class MemoryMemberRepository implements MemberRepository {
    private static final Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

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

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
    }

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

    public void clearStore() {
        store.clear();
    }
}

이 코드는 학습용으로는 충분하지만, 실제 서비스라면 HashMaplong sequence는 동시성 문제가 있다. 최소한 ConcurrentHashMap, AtomicLong 같은 선택을 고려해야 한다. 더 중요한 점은 메모리 저장소가 임시 구현체라는 사실을 코드 구조가 드러내야 한다는 것이다.

Service는 비즈니스 규칙을 숨기지 않는다

회원 가입에서 핵심 규칙은 중복 이름을 허용하지 않는 것이다. 이 규칙은 Controller에 있으면 안 된다. HTTP 폼으로 들어오든, REST API로 들어오든, 배치 작업에서 호출하든 같은 규칙이 적용되어야 하기 때문이다.

public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
            .ifPresent(m -> {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            });
    }

    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

처음 코드에서는 Service가 직접 new MemoryMemberRepository()를 만들 수 있다. 하지만 그렇게 하면 테스트와 구현체 교체가 어려워진다. 생성자 주입으로 바꾸면 Service는 Repository 인터페이스만 의존한다.

// before
private final MemberRepository memberRepository = new MemoryMemberRepository();

// after
private final MemberRepository memberRepository;

public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
}

이 차이는 작아 보이지만, 나중에 Memory Repository에서 JDBC, JPA, Spring Data JPA로 넘어갈 때 Service 코드를 지키는 기준이 된다. 저장소는 바뀌어도 비즈니스 규칙은 흔들리지 않아야 한다.

테스트는 상태 공유를 끊는 것부터 시작한다

메모리 Repository 테스트에서는 각 테스트가 같은 저장소 상태를 공유하면 순서에 따라 실패할 수 있다. 테스트는 독립적으로 실행되어야 하므로 @AfterEach에서 저장소를 비운다.

class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(result).isEqualTo(member);
    }
}

Service 테스트는 Repository를 주입해 같은 저장소를 바라보게 만든다.

class MemberServiceTest {
    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void beforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }

    @AfterEach
    public void afterEach() {
        memberRepository.clearStore();
    }

    @Test
    public void 중복_회원_예외() {
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        memberService.join(member1);

        IllegalStateException e = assertThrows(
            IllegalStateException.class,
            () -> memberService.join(member2)
        );

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}

테스트에서 확인하는 것은 “코드가 실행된다”가 아니다. 중복 이름이라는 규칙이 깨지지 않는지, 테스트 간 상태가 섞이지 않는지, 구현체를 바꿔도 Service 계약이 유지되는지 확인한다.

MVC는 HTTP 경계를 다루는 계층이다

웹 MVC를 붙이면 Controller가 HTTP 요청을 받아 Service를 호출하고 View나 응답을 반환한다. 홈 화면은 단순히 / 요청을 템플릿으로 연결한다.

@Controller
public class HomeController {
    @GetMapping("/")
    public String home() {
        return "home";
    }
}

등록 폼은 GET으로 화면을 보여주고, POST로 데이터를 받는다.

@Controller
public class MemberController {
    private final MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/members/new")
    public String createForm() {
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    public String create(MemberForm form) {
        Member member = new Member();
        member.setName(form.getName());
        memberService.join(member);
        return "redirect:/";
    }
}

조회는 Service에서 목록을 가져와 Model에 담는다.

@GetMapping("/members")
public String list(Model model) {
    List<Member> members = memberService.findMembers();
    model.addAttribute("members", members);
    return "members/memberList";
}

REST API로 바꾸면 View 대신 JSON을 반환하고, DTO 검증과 예외 응답 형식이 더 중요해진다. Spring Boot든 FastAPI든 NestJS든 구조는 비슷하다. HTTP boundary는 요청 검증과 응답 형식을 다루고, 비즈니스 규칙은 Service나 Use Case 계층에 남기는 편이 유지보수하기 쉽다.

DB 접근 방식은 생산성과 통제 사이에서 바뀐다

처음에는 H2 같은 가벼운 DB로 시작할 수 있다. 개발/테스트 용도로 빠르게 실행하고, DDL로 테이블을 만든다.

drop table if exists member cascade;

create table member (
    id bigint generated by default as identity,
    name varchar(255),
    primary key (id)
);

순수 JDBC는 DB 접근 과정을 가장 직접적으로 보여준다. Connection, PreparedStatement, ResultSet을 다루고, 리소스를 닫아야 한다. 반복이 많지만 DB 접근의 기본 구조를 이해하기에는 좋다.

Spring에서는 DataSource를 주입받아 Repository 구현체를 바꿀 수 있다.

@Bean
public MemberRepository memberRepository() {
    return new JdbcMemberRepository(dataSource);
}

JdbcTemplate은 반복 코드를 줄이지만 SQL은 직접 작성한다. SQL 통제력을 유지하면서 생산성을 높이는 중간 단계다.

public class JdbcTemplateMemberRepository implements MemberRepository {
    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
}

JPA는 객체와 테이블 매핑을 통해 SQL 작성 부담을 줄인다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

JPA의 데이터 변경은 트랜잭션 안에서 수행되어야 한다.

@Transactional
public class MemberService {
}

Spring Data JPA는 Repository 구현을 더 줄인다.

public interface SpringDataJpaMemberRepository
    extends JpaRepository<Member, Long>, MemberRepository {

    Optional<Member> findByName(String name);
}

기술 선택은 “최신이라서”가 아니라 어떤 수준의 통제가 필요한지에 따라 달라진다. SQL 튜닝과 복잡한 쿼리가 중요하면 SQL을 명시적으로 다루는 방식이 편할 수 있고, 도메인 모델과 CRUD 생산성이 중요하면 JPA/Spring Data JPA가 유리하다.

SQL은 API 성능과 정합성의 언어다

SQL은 단순 조회 문법이 아니라 데이터 정합성과 성능을 다루는 언어다. DDL은 구조를 만들고, DML은 데이터를 조작하며, TCL은 트랜잭션을 다룬다.

create table employees (
    emp_id int primary key,
    name varchar(50) not null,
    department varchar(50),
    salary decimal(10, 2),
    hire_date date default current_date
);

조회는 필요한 컬럼과 조건, 정렬, 집계를 명확히 표현해야 한다.

select department, avg(salary) as avg_salary
from employees
group by department
having avg(salary) > 4000;

JOIN은 테이블 간 관계를 API 응답에 맞게 조합한다.

select e.name, d.dept_name
from employees e
inner join departments d
    on e.department = d.dept_id;

트랜잭션은 여러 변경을 하나의 논리 단위로 묶는다.

begin;

update accounts set balance = balance - 100 where acc_id = 1;
update accounts set balance = balance + 100 where acc_id = 2;

commit;
-- rollback;

API에서 “주문 생성”처럼 여러 테이블을 바꾸는 기능은 트랜잭션 경계를 잘못 잡으면 일부만 성공하는 상태가 생긴다. 반대로 너무 넓은 트랜잭션은 락 경합과 성능 문제를 만들 수 있다.

인덱스는 조회 속도를 높이지만 쓰기 비용을 만든다.

create index idx_salary on employees(salary);

그래서 인덱스는 “자주 조회하니까 만든다”보다 WHERE, JOIN, ORDER BY, cardinality, 쓰기 빈도를 함께 보고 결정해야 한다.

NoSQL은 유연하지만 접근 패턴을 먼저 정해야 한다

MongoDB는 문서 기반 데이터베이스다. JSON과 비슷한 문서를 저장하고, 스키마가 유연하다. 예를 들어 책 데이터는 중첩된 저자 정보와 카테고리 배열을 한 문서에 담을 수 있다.

db.book.insert({
  "title": "React",
  "author": {
    "writer": "Jane",
    "translator": "Alice"
  },
  "price": 40000,
  "categories": ["IT", "Web", "Front-End"]
});

조회도 문서 구조와 연산자를 기준으로 한다.

db.emp.find({ deptno: 30, job: "MANAGER" }, { deptno: 1, ename: 1, job: 1 });
db.emp.find({ $or: [{ sal: 1500 }, { sal: 1600 }] }, { ename: 1, sal: 1 });
db.emp.find({ ename: { $regex: /^S/ } });
db.emp.find().skip(10).limit(2);

MongoDB는 스키마가 유연하다는 장점이 있지만, 아무 구조 없이 넣어도 된다는 뜻은 아니다. 어떤 필드로 조회할지, 어떤 문서를 함께 읽을지, 업데이트 단위가 어디인지 먼저 생각해야 한다.

DynamoDB 같은 키-값/문서형 NoSQL은 더 강하게 접근 패턴 중심으로 설계해야 한다. Query는 파티션 키를 기준으로 효율적으로 읽지만, Scan은 전체 테이블을 훑기 때문에 비용과 지연이 커진다. RDB에서 정규화와 JOIN을 먼저 생각하던 습관으로 접근하면 DynamoDB 설계가 어긋나기 쉽다.

RDB
- relationship first
- join and transaction
- flexible query after modeling

NoSQL
- access pattern first
- partition key and sort key
- denormalization and duplication when needed

모놀리식에서 MSA로 나눌 때 먼저 나눌 것은 코드가 아니라 책임이다

Cloud Native Application이나 MSA를 이야기할 때 컨테이너, Kubernetes, API Gateway가 먼저 떠오르지만, 실제로 먼저 결정해야 하는 것은 서비스 경계다. 모놀리식 애플리케이션을 무작정 여러 서비스로 나누면 배포 단위는 나뉘지만 데이터 정합성, 네트워크 실패, 인증, 관측성이 더 어려워진다.

서비스 분리는 보통 다음 순서로 생각하는 편이 안전하다.

Monolith
  -> module by domain
  -> clear service interface
  -> separate data ownership
  -> independent deployment
  -> independent scaling

회원, 주문, 결제, 알림이 있다고 해서 처음부터 네 개의 서비스를 만들 필요는 없다. 회원 정보가 주문과 결제에 어떤 방식으로 참조되는지, 결제 실패가 주문 상태에 어떤 영향을 주는지, 알림 발송 실패가 핵심 트랜잭션을 막아야 하는지 먼저 정리해야 한다.

MSA에서 중요한 원칙 중 하나는 서비스가 자기 데이터를 소유한다는 점이다. 여러 서비스가 하나의 DB 테이블을 공유하면 배포 단위는 나뉘어도 실제 결합은 그대로 남는다.

avoid
  order service -> member table direct query
  payment service -> order table direct update

prefer
  order service -> member API or cached read model
  payment service -> payment result event
  order service -> updates order state by own transaction

물론 모든 조회를 실시간 API 호출로만 처리하면 지연과 장애 전파가 커진다. 그래서 read model, event, cache, eventual consistency 같은 선택지가 등장한다. 이때도 핵심은 기술 선택보다 “어떤 데이터가 어느 서비스의 기준 데이터인가”를 명확히 하는 것이다.

MSA 개발에서 흔한 안티 패턴은 다음과 같다.

distributed monolith
  - services are deployed separately
  - but always changed and released together

shared database
  - multiple services read/write same tables
  - schema change becomes cross-team incident

chatty communication
  - one user request triggers many synchronous calls
  - latency and failure amplification increase

missing observability
  - logs exist per service
  - but no trace id connects the request path

이 문제를 줄이려면 API Gateway, service discovery, centralized logging, distributed tracing, contract testing이 필요해진다. 하지만 도구를 붙이는 것만으로 MSA가 완성되지는 않는다. 작게 시작한 모놀리식 코드에서도 controller, service, repository, domain 경계가 분명해야 나중에 서비스 경계를 잡기 쉽다.

캐시는 성능 해결책이면서 일관성 문제다

Redis는 캐시, 세션성 데이터, rate limit, 작업 상태 저장, pub/sub 등 다양한 용도로 쓰인다. 하지만 캐시는 무조건 넣는다고 시스템이 좋아지지 않는다. 오히려 DB와 캐시 사이의 일관성 문제가 생긴다.

읽기 캐시를 붙일 때는 최소한 다음을 정해야 한다.

Cache decision
- cache key format
- TTL
- stale data tolerance
- invalidation trigger
- fallback when Redis is unavailable
- metrics: hit rate, latency, error rate

예를 들어 회원 프로필 조회에 캐시를 붙이면 읽기 지연은 줄어든다. 하지만 회원 정보가 수정되었을 때 캐시를 즉시 지울지, 짧은 TTL로 자연 만료시킬지 결정해야 한다. Redis 장애 시 DB로 fallback할지, 일부 기능을 제한할지도 정해야 한다.

캐시는 DB 인덱스 문제를 숨기기도 한다. 캐시 hit rate가 높을 때는 빨라 보이지만, 캐시가 비는 순간 DB가 버티지 못할 수 있다. 그래서 캐시 도입 전후로 DB 쿼리 계획과 인덱스도 같이 확인해야 한다.

AOP와 관측성: 공통 관심사를 분리하기

서비스 메서드마다 실행 시간을 재고 싶다고 모든 메서드에 System.currentTimeMillis()를 넣으면 핵심 로직과 측정 코드가 섞인다.

public Long join(Member member) {
    long start = System.currentTimeMillis();
    try {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    } finally {
        long finish = System.currentTimeMillis();
        System.out.println("join " + (finish - start) + "ms");
    }
}

AOP는 이런 공통 관심사를 분리한다.

@Component
@Aspect
public class TimeTraceAop {
    @Around("execution(* hello.hellospring..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("END: " + joinPoint + " " + timeMs + "ms");
        }
    }
}

Spring AOP는 프록시를 통해 실제 객체 호출 전후에 공통 로직을 삽입한다. 학습 예제에서는 시간 출력으로 충분하지만, 운영에서는 structured logging, metrics, tracing으로 이어진다. FastAPI에서는 middleware/dependency, NestJS에서는 interceptor/guard/filter가 비슷한 역할을 한다.

정리

작은 회원 관리 예제에서 출발해도 백엔드 설계의 핵심은 꽤 많이 드러난다. Controller는 HTTP 경계를 다루고, Service는 비즈니스 규칙을 유지하고, Repository는 데이터 접근 구현을 숨긴다. 테스트는 상태 공유를 끊고, DB 접근 기술은 생산성과 통제 사이에서 선택한다.

SQL은 트랜잭션과 정합성, JOIN, 인덱스를 다루는 기반이고, MongoDB나 DynamoDB 같은 NoSQL은 접근 패턴과 확장성 요구에 따라 선택해야 한다. Redis는 성능을 높일 수 있지만 일관성 전략을 함께 요구한다.

결국 백엔드 API는 엔드포인트 목록이 아니라 데이터와 운영 경계 위에 놓인 시스템이다. 어떤 요청이 들어오고, 어떤 규칙이 적용되고, 어떤 저장소에 어떤 트랜잭션으로 반영되며, 문제가 생겼을 때 어떤 로그와 지표로 추적할 수 있는지까지 이어져야 API 설계가 단단해진다.

이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.