실무에서 REST API를 개발하면서 항상 DTO와 Entity를 분리해서 사용했습니다. 그 이유는 먼저 Entity는 데이터베이스와 직접 매핑되기 때문에 불필요한 정보가 외부로 노출될 수 있고, 변경 시 의도치 않게 DB 구조까지 영향을 줄 수 있습니다.
반면 DTO는 요청/응답에 맞게 필요한 필드만 포함시키고, 보안적으로도 민감 정보는 제외할 수 있어서 안정적입니다.
예를 들어, 유저 정보를 조회할 때 비밀번호나 권한은 숨기고 이름, 이메일만 반환하는 UserResponseDto
를 따로 만들어 사용했었습니다.
꼬리질문:
네, 작은 프로젝트에서는 수동 매핑을 선호했어요. 명시적으로 어떤 필드가 매핑되는지 알 수 있어서요. 대규모 프로젝트에서는 MapStruct
도 일부 도입해서 자동화한 적 있습니다.
게시글 목록이나 사용자 활동 이력 같은 리스트 조회 기능에서 자주 사용했습니다.
컨트롤러에서는 @RequestParam
으로 page, size, sort 정보를 받고, 서비스에서 PageRequest.of()
로 Pageable
객체를 생성해서 Repository에 넘겼습니다.
예를 들어 Page<Post>
형태로 결과를 받아서 getContent()
, getTotalPages()
등을 추출해 API 응답 포맷에 맞게 DTO로 가공했습니다.
@Transactional
은 트랜잭션 범위를 지정하는 어노테이션으로, 메서드 실행 중 예외가 발생하면 자동으로 롤백시켜줍니다.
실무에선 서비스 계층에 주로 붙여서 사용했고, 특히 여러 Repository가 동시에 변경되는 작업에서 유용했습니다.
꼬리질문:
readOnly = true
옵션은 써보셨나요?네, 조회 전용 메서드에는 @Transactional(readOnly = true)
를 붙이면 성능이 조금 향상되는 걸로 알고 있고, JPA가 더 이상 dirty checking을 하지 않아서 불필요한 리소스를 아낄 수 있었습니다.
네, 트랜잭션은 서비스 계층에 걸어야 한다고 생각합니다.
왜냐하면 서비스 계층이 실제 비즈니스 로직을 처리하고 여러 DAO나 외부 호출을 조합하는 곳이기 때문입니다.
컨트롤러는 단순히 요청을 전달하는 역할이고, Repository는 DB 단위의 세부작업만 담당하니까, 트랜잭션 책임을 맡기기엔 적절하지 않다고 봅니다.
또한 서비스 계층에 트랜잭션을 설정하면 하위의 여러 Repository 호출이 하나의 트랜잭션으로 묶여서 처리되기 때문에, 정합성 유지에도 유리합니다.
네, 사실 두 가지 방식을 비교해봤습니다.
하지만 당시 업무 요구사항에서는 한 번의 수집 배치에서 모든 주문이 가공·저장돼야 '완전한 단위'로 간주됐습니다.
그래서 일부 주문만 저장되고, 나머지가 빠지는 상황은 피해야 했습니다.
예를 들어 수집한 50건 중 48건만 저장되면, 다시 수집하거나 보정하는 작업이 더 복잡해집니다.
다만 예외 처리나 재시도 정책이 있다면 개별 트랜잭션도 고려해볼 수 있을 것 같습니다.
맞습니다. 그래서 한 번에 수백 건씩 처리하지 않고, 청크 단위로 잘라서 트랜잭션을 관리했습니다.
1000건 단위로 나눠서 수집하고, 청크별로 실패 시 별도 재처리 큐로 넘기는 방식으로 구현했어요.
또한 배치 특성상 사용자와 실시간 인터랙션이 없기 때문에, 어느 정도 트랜잭션 시간이 길어져도 문제는 없었고, DB 연결 시간이나 커넥션 풀 제한만 조심해서 튜닝했습니다.
초기에는 트랜잭션이 너무 길어져서 락이 오래 잡히는 문제가 있었습니다.
그래서 가공이 오래 걸리는 부분은 트랜잭션 외부로 분리했고, DB 저장이 필요한 로직만 트랜잭션 내부에 최소화해서 넣는 식으로 개선했습니다.
또한 가능하면 readOnly = true
를 명시하거나, 조회 로직은 분리해서 처리하면서 성능 이슈를 최소화했습니다.
REQUIRED
는 기존 트랜잭션이 있으면 그 안에 합류하는 방식이고, REQUIRES_NEW
는 기존 트랜잭션을 잠시 보류하고 새로운 트랜잭션을 시작합니다.
그래서 주문 처리 중 실패하더라도 로그는 반드시 DB에 남겨야 하는 경우라면, REQUIRES_NEW
를 사용해야 합니다.
실무에서는 로그, 이력, 알림 전송 같은 비핵심 부가 로직을 REQUIRES_NEW로 분리해놓고 트러블슈팅에 활용하는 경우가 많았습니다.
네, 실무에서도 이 구조를 명확히 구분해서 작업했습니다.
예를 들어 주문 수집 기능을 만들 때, Controller에서는 주문 수집 요청을 받고, Service에서는 API에서 받아온 주문 데이터를 가공해 처리했고, Repository에서는 최종 주문 수집 데이터를 저장했습니다.
꼬리질문:
단기적인 개발에서는 가능할 수도 있지만, 그렇게 되면 재사용성이나 유지보수성이 떨어집니다.
특히 서비스 로직이 복잡해질 경우 컨트롤러가 비대해지고, 트랜잭션 처리도 어려워지기 때문에 분리를 유지하는 게 좋다고 생각합니다.
네, 사용해봤습니다.
주로 컨트롤러에서 클라이언트로부터 전달받은 DTO 객체의 유효성 검사를 할 때 사용했습니다.
예를 들어, 사용자 생성 API에서 @RequestBody로 받은 DTO에 @NotBlank, @Size 같은 제약을 지정해두고,
컨트롤러 메서드 파라미터에 @Valid를 붙여서 요청값 검증이 자동으로 수행되도록 했습니다.
가장 중요한 건 연관관계의 주인을 명확히 설정하는 거라고 생각합니다. 주인은 외래키를 관리하는 쪽이고, 보통 @ManyToOne
쪽이 주인이 되죠.
그리고 양쪽 필드 값을 동시에 관리해줘야 데이터 정합성이 깨지지 않기 때문에, addXXX()
같은 편의 메서드를 만들어서 두 객체에 모두 값을 넣어주는 식으로 처리했습니다.
실무에서는 사실 양방향 연관관계를 자주 사용하지 않았습니다.
왜냐하면 연관관계를 잘못 설정하면 무한 루프
, 데이터 정합성 문제
, 예상치 못한 N+1 문제
등 다양한 이슈가 발생하기 쉽습니다.
대신 단방향 연관관계를 기본으로 두고, 필요한 경우에만 명확한 주체를 정해서 양방향으로 설정했습니다.
그리고 연관된 데이터를 조회할 필요가 있을 땐 JPQL, QueryDSL, DTO projection을 활용하는 식으로 설계를 했습니다.
양방향보다는 단방향이 설계가 단순하고 유지보수도 쉬워서, 가능하면 단방향으로 구성하는 걸 선호했습니다.
꼬리질문:
네, 부모-자식 관계에서 자식 엔티티를 함께 저장하거나 삭제할 때 CascadeType.ALL
또는 REMOVE
를 사용했습니다.
단, 무분별하게 설정하면 예기치 않은 삭제가 발생할 수 있어서 꼭 필요한 경우에만 적용했습니다.
구분 | JPQL | QueryDSL |
---|---|---|
작성 방식 | 문자열 기반 | Java 코드 기반 (타입 안전) |
적합한 상황 | 단순한 쿼리, 정적 쿼리, 복잡하지 않은 join | 복잡한 조건, 동적 쿼리, 조건이 많은 검색 |
가독성 | 짧은 쿼리는 오히려 보기 쉬움 | 쿼리가 길어지면 유지보수 편함 |
재사용성 | 재사용 어려움 (하드코딩된 문자열) | 메서드로 분리해 재사용 용이 |
런타임 오류 | 존재 가능 (문자열 파싱 실패 등) | 컴파일 타임 오류로 사전 방지 가능 |
DTO 매핑 | new 키워드 사용, 오타 위험 |
Projections.bean 등으로 안정적 매핑 가능 |
단점 | 유지보수 어려움, IDE 지원 약함 | 초기 세팅 번거롭고 문법 장벽 있음 |
단순한 정적 쿼리의 경우에는 JPQL을 사용했습니다.
예를 들어, 특정 상태값을 가진 리스트를 조회하거나 단순한 정렬이 필요한 경우 등입니다.
반면에, 조건이 유동적으로 바뀌는 검색 화면이나 필터 조건이 많은 쿼리의 경우에는 QueryDSL을 사용했습니다.
예를 들어, 검색 조건이 5~6개 이상이고 where 절이 동적으로 조합되어야 하는 경우,
BooleanBuilder 또는 where(...).and(...) 구조를 쓰는 QueryDSL이 훨씬 유지보수에 유리했습니다.