REST API 설계와 DTO: 포장지 없는 선물은 위험하다

“어? 비밀번호가 왜 보여?”

입사 초기, 나는 프론트엔드와 백엔드를 오가며 정신없이 개발하고 있었다. 어느 날, 입사 동기가 개발한 ‘회원 목록 조회 API’를 프론트엔드 화면에 연동하던 중이었다.

데이터가 잘 들어오나 확인하려고 크롬 개발자 도구(F12)의 네트워크 탭을 열었는데, 응답(Response) 데이터를 본 순간 내 눈을 의심했다.

[
  {
    "id": 1,
    "username": "tester",
    "password": "$2a$10$D...", // 암호화된 비밀번호 노출
    "ssn": "900101-1...",      // 주민번호 노출
    "createdAt": "2024-01-01"
  }
]

깜짝 놀라서 동기에게 물어봤다. “야, 이거 API 응답에 비밀번호랑 주민번호가 다 보이는데?”

동기는 쿨하게 대답했다. “아, 그거? 어차피 화면에는 이름만 뿌리잖아? 귀찮아서 그냥 userRepository.findAll() 결과 그대로 리턴했는데 문제 있어?”

등골이 오싹했다. 동기는 단순히 ‘편리함’을 추구했을 뿐인데, 결과적으로 심각한 ‘보안 사고’의 씨앗을 심은 셈이었다.

“화면에 안 보인다고 안전한 게 아니야!”

브라우저 네트워크 탭은 누구나 볼 수 있다. 만약 악의적인 해커가 이 API를 호출했다면?

  1. 개인정보 유출: 모든 회원의 주민번호와 전화번호가 털린다.
  2. 계정 탈취: 암호화된 비밀번호라도 무차별 대입 공격(Brute Force)의 단서가 될 수 있다.
  3. 내부 로직 노출: createdAt, createdBy 같은 필드를 통해 DB 구조나 관리자 ID 패턴이 파악될 수 있다.

엔티티를 그대로 내보내는 건, 우리 집 안방을 투명 유리창으로 만드는 것과 다름없었다.

내용물이 훤히 보이는 투명 봉투는 도둑에게 초대장이나 다름없다.

DTO: 데이터의 ‘포장지’

‘엔티티(Entity)’는 공장 내부에서만 취급하는 ‘원자재’다. 여기에는 고객에게 보여줘선 안 될 기밀(비밀번호)이나, 내부 관리용 정보(생성일, 수정일, 삭제 여부)가 덕지덕지 붙어 있다.

반면 ‘DTO(Data Transfer Object)’는 고객에게 배달하기 위해 예쁘게 포장된 ‘완제품’이다.

  • Entity: User (id, password, name, ssn, createdAt, createdBy…) -> 공장용
  • DTO: UserResponse (id, name) -> 배달용

엔티티를 직접 반환한다는 건, 식당에서 손님에게 요리된 스테이크를 주는 게 아니라, 피 묻은 생고기를 접시에 던져주는 것과 같다. 우리는 반드시 ‘변환(Mapping)’ 과정을 거쳐야 한다.

[Code Verification] 무한 루프의 늪 (순환 참조)

보안 문제보다 개발자를 더 미치게 만드는 건 바로 ‘순환 참조(Circular Reference)’ 문제다. 지난 시간에 배운 JPA의 양방향 매핑(User <-> Team)을 기억하는가?

만약 User 엔티티를 그대로 JSON으로 변환해서 반환하면 어떻게 될까?

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne // 유저는 팀에 소속된다.
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team") // 팀은 여러 유저를 가진다.
    private List<User> users = new ArrayList<>();
}

이 상태에서 User를 리턴하면, JSON 변환기(Jackson)는 이런 식으로 동작한다.

  1. User를 깐다. -> 안에 Team이 있네?
  2. Team을 깐다. -> 안에 Users가 있네?
  3. Users 리스트의 첫 번째 User를 깐다. -> 안에 Team이 있네?
  4. Team을 깐다. -> 안에 Users가 있네?
  5. (무한 반복…)

결과: StackOverflowError와 함께 서버가 장렬하게 전사한다. 엔티티 간의 관계는 서로 꼬리에 꼬리를 물고 있기 때문에, 이를 끊어주지 않고 그대로 내보내면 ‘무한 루프’에 빠진다. DTO를 써서 필요한 필드(teamName)만 딱 끊어서 담아야 하는 결정적인 이유다.

관계를 끊어내지 않으면(DTO), 데이터는 영원히 회전한다.

좋은 API의 조건: RESTful

DTO로 데이터를 잘 포장했다면, 이제 이 택배를 보낼 ‘주소’를 잘 정해야 한다. 이것이 바로 ‘REST API 설계’다.

학부 때는 URL을 내 마음대로 지었다.

  • 회원가입: /joinUser
  • 회원수정: /updateUser
  • 회원삭제: /deleteUser

동사(join, update)가 URL에 들어가는 건 좋지 않다. RESTful한 설계는 ‘자원(명사)’은 URL에, ‘행위(동사)’는 HTTP Method에 맡기는 것이다.

  • POST /users: 회원가입 (만들어라)
  • GET /users/1: 1번 회원 조회 (가져와라)
  • PUT /users/1: 1번 회원 수정 (갈아끼워라)
  • DELETE /users/1: 1번 회원 삭제 (지워라)

이렇게 짜면 URL만 봐도 “아, 유저 자원을 다루는구나”라고 알 수 있고, 프론트엔드 개발자와 소통하기도 훨씬 편해진다. 이것이 전 세계 개발자들의 공통 약속이다.

실무 조언: 이름이 반이다 (DTO 네이밍 전략)

지금 회사 프로젝트 코드를 열어보면 가끔 한숨부터 나온다. UserDto, MemberVo, InfoParam, ResultData… 전임자들이 자기 마음대로 이름을 지어놔서, 이게 요청을 받는 객체인지 응답을 주는 객체인지 코드를 까보기 전엔 알 수가 없다.

DTO는 데이터의 ‘그릇’이다. 그릇의 용도가 이름에 안 쓰여 있으면, 국그릇에 밥을 담고 밥그릇에 물을 담는 혼란이 생긴다. 실무에서 가장 추천하는 네이밍 규칙은 다음과 같다.

  1. 접미사(Suffix)로 역할을 명시하라
    • 요청(Request):행위 + 도메인 + Request
      • 예: JoinUserRequest, UpdateUserRequest
    • 응답(Response):도메인 + Response
      • 예: UserResponse, UserListResponse
    • 단순히 UserDto라고 쓰면, 이게 가입할 때 쓰는 건지 조회할 때 쓰는 건지 헷갈려서 매번 파일을 열어봐야 한다.
  2. 이너 클래스(Inner Class)로 그룹화하라 파일이 너무 많아지는 게 싫다면, ‘이너 클래스’ 전략을 강력 추천한다. UserDto라는 클래스 하나를 만들고, 그 안에 관련된 DTO들을 static class로 몰아넣는 것이다. 이렇게 하면 코드에서 UserDto.SignUpRequest라고 쓰게 되어, “아, 유저 관련 DTO구나” 하고 소속이 명확해진다.
public class UserDto {
    // 회원가입 요청
    public static class SignUpRequest {
        private String email;
        private String password;
    }

    // 회원정보 응답
    public static class Response {
        private String name;
        private String email;
    }
}

시리즈 2를 마치며: 이제 집은 지어졌다

이것으로 [Monolith Builder] 시리즈가 끝났다. 우리는 스프링 부트(DI)로 기초를 다지고, 프론트/백엔드를 분리(CORS)했으며, JPA로 DB를 다루고, DTO와 REST API로 소통하는 법까지 익혔다.

이제 우리는 “혼자서도 번듯한 웹 서비스를 만들 수 있는 능력”을 갖추게 되었다. 하지만 개발자의 여정은 여기서 끝이 아니다. 내가 만든 서비스가 ‘내 컴퓨터(Localhost)’에서만 잘 돌아가면 무슨 소용인가? 실제 사용자에게 보여주려면 ‘서버(Server)’에 올려야 한다.

그런데 서버 컴퓨터는 윈도우가 아니라 검은 화면뿐인 ‘리눅스(Linux)’다. 마우스도 없는 그곳에서 어떻게 프로그램을 설치하고 실행할까?

다음 시리즈 [Works on My Machine]에서는, 개발자들의 영원한 난제인 “내 컴퓨터에선 되는데 서버에선 안 돼요”를 해결하기 위한 ‘리눅스와 도커(Docker)’의 세계로 떠나보자.

댓글 남기기