[Spring] Author API 실습: 바인딩/응답/초기화 이슈(트러블슈팅)

이지연·2026년 1월 19일

개요

이번 실습의 목표는 “Board 서비스”에서 Author(회원) 도메인에 대한 기본 CRUD API(회원가입/목록/상세/삭제)를 직접 설계하고 구현해보는 것이었다.
원본 코드(Controller/Service/Repository/DTO/Domain)를 기준으로, 어떤 요구사항을 어떤 형태로 처리했는지 정리한다.
또한 이 떄 발생한 에러에 대해 중점적으로 정리해보았다.

1) 도메인(Author) 요구사항

Author는 회원을 표현하는 도메인(Entity)로, 아래 필드를 가진다.

  • id: 식별자(PK 역할)
  • name: 이름
  • email: 이메일
  • password: 비밀번호
    실습 단계에서는 DB가 없어서, id는 DB에서 생성되는 대신 Repository에서 staticId로 “증가시키며 부여”하는 형태로 동작하도록 구성되어 있었다(원본 Repository 코드 기준).

2) API 요구사항(원본 Controller 기준)

원본 AuthorController/author를 prefix로 사용하고, 아래 4개의 엔드포인트를 제공한다.

2-1) 회원가입(Create)

  • URL: POST /author/create
  • 요청 바디(JSON): {"name":"lee","email":"lee@naver.com","password":"1234"}
  • 처리 흐름
    • Controller가 AuthorCreateDto로 요청 바디를 받는다.
    • Service에서 Author 엔티티를 조립한 뒤 저장을 위임한다.
  • 응답: "OK" (문자열)
    즉 “요청은 DTO로 받고, 저장은 Entity로 한다”가 기본 요구사항이다.

2-2) 회원목록조회(Read - List)

  • URL: GET /author/list
  • 응답(JSON 배열): List<AuthorListDto>
  • 처리 흐름
    • Repository에서 전체 Author 목록을 가져온다.
    • Service에서 목록 응답용 DTO(AuthorListDto)로 변환해 반환한다.
      목록 조회는 “리스트 응답”이며, 원본 코드 흐름상 목록 DTO는 보통 비밀번호 같은 민감정보를 제외하는 목적을 가진다(원본 DTO 설계 의도 기준).

2-3) 회원상세조회(Read - Detail)

  • URL: GET /author/{id}
  • PathVariable: id
  • 응답(JSON): AuthorDetailDto
  • 처리 흐름
    • id로 단건 조회한다.
    • 없으면 예외를 발생시키는 방식으로 처리한다(NoSuchElementException).
      상세 조회는 “단건 응답”이고, 원본 코드에서는 detail DTO에 password까지 포함되어 있었다(즉 목록/상세의 응답 형태를 DTO로 분리한 요구사항).

2-4) 회원탈퇴(Delete)

  • URL: DELETE /author/{id}
  • PathVariable: id
  • 응답: "OK" (문자열)
  • 원본 상태
    • 실제 삭제 로직(Repository에서 제거)은 구현되어 있지 않고, 로그 출력만 수행한다.
      즉 삭제 API는 “URL/메서드 설계까지 포함한 요구사항”으로 먼저 만들어두고, 내부 구현은 추후로 미뤄둔 상태다.

3) DTO 요구사항(요청/응답 분리)

원본 코드 기준으로 DTO는 목적별로 분리되어 있다.

  • AuthorCreateDto: 회원가입 요청을 받기 위한 DTO (name/email/password)
  • AuthorListDto: 목록 응답 DTO (id/name/email)
  • AuthorDetailDto: 상세 응답 DTO (id/name/email/password)
    여기서 핵심 요구사항은 “서비스 목적에 따라 응답 형태가 달라지므로, Entity 하나로 요청/응답을 다 커버하지 않는다”는 점이다.
    즉 Entity는 DB 구조(원형)이고, 요청/응답은 DTO로 분리한다.

4) 저장소 요구사항(DB 대신 List)

원본 Repository는 실제 DB 통신 대신, 아래 방식으로 동작한다.

  • 내부 저장: List<Author>
  • ID 부여: staticId++로 증가시키며 부여
  • 조회
    • 전체 조회: 리스트 반환
    • 단건 조회: for문으로 id 탐색 후 Optional 반환
      즉 “DB를 붙이기 전 단계에서 CRUD 흐름과 계층 분리를 연습”하는 것이 원본 실습의 핵심 요구사항이다.

트러블

Author API(회원가입/목록/상세)를 만들면서 “코드는 맞는 것 같은데 결과가 계속 이상하게 나오는” 트러블을 겪었다.
결론부터 말하면, 대부분의 문제는 DB 없이 List로 레포지토리를 흉내 내는 구조 + DI 없이 new로 의존성을 연결한 구조에서 발생했다.

이번 글에서는 아래 3가지를 트러블슈팅 관점으로 정리한다.

  • ID를 언제/누가 만들까? (DTO/Entity 구조 차이 + DB 부재)
  • 객체가 매번 초기화됨 (Controller에서 Service를 매번 new)
  • Service에서도 Repository가 리셋됨 (Service에서 Repository를 매번 new)

1) ID를 언제/누가 만들까?

증상

Author를 저장하는데 id가 없거나, id를 어디서 세팅해야 하는지 애매해진다.
특히 “컨트롤러에서 값이 제대로 담겨서 서비스/레포지토리까지 내려갔나?” 같은 의문이 생긴다.

원인

  • 컨트롤러에서 주로 다루는 건 DTO(요청/응답용)인데, DB에 들어가는 건 Domain(Entity)이다.
  • DTO와 Entity 구조는 서비스 목적에 따라 달라질 수 있어서, 컨트롤러 단계에서 “완벽한 도메인 객체”를 만드는 게 애매해질 수 있다.
  • 그리고 ID는 원래 DB insert 이후에 확정되는 값이라, DB가 없는 상태에서는 “정석적으로는” 알 수 없다.

임시 해결(실습용)

DB 대신 List를 쓰는 in-memory 레포지토리에서는 id 역할이 필요하니까, 레포지토리에서 static 값을 증가시키며 PK를 흉내낸다.

private List<Author> authorList = new ArrayList<>();
private static Long staticId = 1L;

author.setId(staticId++);

포인트

  • 이건 “실제 설계”라기보다 DB가 없으니 식별자 개념만 흉내낸 임시 구현이다.
  • 실무에서는 보통 DB가 ID를 생성하고(예: auto increment), 애플리케이션이 id를 직접 set하지 않는다.

2) 객체가 매번 초기화됨: Controller에서 Service를 매번 new

증상

요청을 여러 번 보내면 이전에 저장했던 데이터가 사라진 것처럼 보인다.
한 번 저장하고 목록 조회하면 잘 나오는데, 다음 요청부터 갑자기 비어있거나 id가 이상해지는 식으로 흐름이 깨진다.

원인

컨트롤러 메서드가 실행될 때마다 new AuthorService() 같은 방식으로 서비스를 생성하면, 서비스 내부 상태/참조들도 매번 초기화된다.
즉 “서버는 계속 떠 있는데, 내 코드가 요청마다 새 객체를 만들어서 계속 리셋시키는 상황”이 된다.

임시 해결(당시)

컨트롤러 생성 시점에 서비스 객체를 1번만 만들고 필드로 재사용한다.

private AuthorService authorService;

public AuthorController() {
    this.authorService = new AuthorService();
}

근본 해결(현재 방향)

스프링 DI로 해결한다.
컨트롤러는 서비스를 직접 new로 만들지 않고 주입받는 구조로 바꾸는 게 정석이다(생성자 주입 등).


3) Service에서도 Repository가 리셋됨: Service에서 Repository를 매번 new

증상

컨트롤러에서 서비스를 “재사용”하도록 바꿨는데도 데이터가 여전히 저장이 안 되는 느낌이 든다.
원인을 더 파보면 서비스 내부에서 레포지토리를 매번 생성하거나(또는 서비스 생성 시마다 새 레포지토리가 연결되는 구조) 상태가 유지되지 않는 케이스가 나온다.

원인

레포지토리가 List로 데이터를 들고 있는 in-memory 구조일 때는, 레포지토리 인스턴스가 바뀌는 순간 데이터가 “다른 통”으로 들어가 버린다.
new AuthorRepository()가 계속 일어나면 저장소가 계속 새로 만들어지는 것과 같다.

임시 해결(당시)

서비스 생성자에서 레포지토리를 1번만 만들고 필드로 들고 있는다.

private AuthorRepository authorRepository;

public AuthorService() {
    authorRepository = new AuthorRepository();
}

근본 해결(현재 방향)

@Repository로 빈 등록 + @Service에서 생성자 주입(DI)으로 받는 구조로 바꾼다.
이렇게 하면 스프링 컨텍스트가 싱글톤 빈을 관리하므로, in-memory 실습에서도 “상태가 유지되는 것처럼” 동작을 맞추기 쉽다.


마무리: 이번 실습에서 얻은 결론

  • DB가 없으면 ID/저장소/상태가 “원래 어디서 만들어지고 유지되는지”가 더 잘 드러난다. 그래서 오히려 학습 포인트가 많다.
  • “요청마다 new”는 in-memory 실습에서 거의 확정적으로 데이터를 날린다.
  • 실무형 구조로 가려면 결국 DI(주입) 기반으로 계층을 연결해야 한다.
profile
Eazy하게

0개의 댓글