[Spring Boot] 프로젝트 refactoring 및 JPA 도입 과정에서 배운 5가지 핵심 Q&A

유자·2026년 2월 28일

multi-modal-fraud-project

목록 보기
9/16

프로젝트를 진행하며 기존 컨트롤러에 혼재되어 있던 로직(Supabase 업로드, AI 서버 통신, DB 저장)을 Service 계층으로 분리하고, DB 저장을 위해 JPA Entity를 도입하는 refactoring을 진행했다.

그동안은 남들이 쓰니까, 혹은 IDE가 자동완성으로 추천해 주니까 별생각 없이 썼던 annotation과 syntax들이 많았다. 하지만 이번에 아키텍처를 뜯어고치면서 "이걸 왜 굳이 이렇게 써야 하지?"라는 꼬리 질문이 계속 생겼고, 그 의문들을 하나하나 파헤치며 알게 된 사실들을 기록해 둔다.

1. @NoArgsConstructor를 붙였는데, 굳이 생성자를 또 만들어야 하는 이유?

JPA를 통해 탐지 결과를 DB에 저장하고자 DetectionResult Entity 클래스를 설계하던 중이었다. 이 클래스에는 DB가 자동으로 번호를 매겨주는 기본키(id)가 있고, 그 외에 서비스 로직에서 판별된 verificationId, imageUrl, status 같은 필수 필드들이 존재한다.

보통은 편의를 위해 Lombok의 @NoArgsConstructor(기본 생성자)와 @AllArgsConstructor(전체 필드 생성자)를 세트로 달아두곤 했다. 하지만 엔티티에서는 @AllArgsConstructor를 쓰기가 난감했다. DB가 알아서 생성해야 할 id 값까지 매개변수로 열어두는 셈이 되기 때문이다. 결국 클래스 상단에는 빈 생성자(@NoArgsConstructor)를 남겨두고, 클래스 내부에는 id를 제외한 필수 필드들만 매개변수로 받는 사용자 정의 생성자를 직접 작성해야 했다.

여기서 의문이 생겼다. "어차피 생성자를 직접 작성할 거면 그것만 쓰면 되지, 굳이 빈 기본 생성자(@NoArgsConstructor)까지 클래스에 남겨두어야 할까?"

결론부터 말하자면, 이 두 생성자는 사용되는 시점과 기술적 목적 자체가 완전히 다르기 때문에 모두 필요하다.

@NoArgsConstructor (JPA 프레임워크용):
이 빈 생성자는 개발자가 코드상에서 new DetectionResult()처럼 직접 호출하기 위해 존재하는 것이 아니다. JPA(Hibernate)가 DB에서 조회한(SELECT) 데이터를 자바 객체(Entity)로 변환할 때 사용하기 위한 필수 장치다. JPA는 내부적으로 Java Reflection API를 사용해 객체를 동적으로 생성하는데, 리플렉션을 통해 객체의 인스턴스를 초기화하려면 파라미터가 없는 기본 생성자가 반드시 존재해야 한다. 만약 이것이 누락되면 프레임워크는 InstantiationException 예외를 발생시키며 객체를 매핑하지 못한다.

직접 작성한 사용자 정의 생성자 (비즈니스 로직용):
반면, 이 생성자는 개발자가 서비스 계층에서 새로운 데이터를 DB에 저장하고자 할 때 직접 사용하기 위한 것이다. new DetectionResult(uuid, url, status)처럼 새로운 객체를 생성하는 시점에, 필요한 필수 데이터만 안전하고 명확하게 초기화하기 위해 작성한다. 앞서 언급했듯 DB의 생성 전략(Auto_Increment 등)에 의해 자동으로 매겨지는 id 필드는 초기화 단계에서 주입할 필요가 없으므로 매개변수에서 제외하는 것이 안전한 객체지향적 설계다.

결론적으로, DB 데이터를 읽어와 객체를 재구성해야 하는 프레임워크의 기술적 요구사항(기본 생성자)과, 새로운 데이터를 안전하게 영속화하려는 비즈니스 로직(사용자 정의 생성자)이라는 두 가지 독립적인 목적을 위해 두 생성자가 모두 공존해야 하는 것이다.

2. Auto_Increment 설정을 DB가 아닌 Java(Entity)에서 해주는 이유

예전 프로젝트에서는 MySQL 같은 DB를 쓸 때, 항상 DB 관리 툴(Workbench 등)에 직접 들어가서 테이블을 만들고 기본키(PK)에 Auto_Increment 옵션을 직접 설정하곤 했다.

그런데 이번에 Supabase(PostgreSQL)를 연결하고 JPA를 도입하면서, DB 콘솔에 들어가서 테이블을 만드는 과정을 생략했다. 대신 Java의 DetectionResult 엔티티 클래스에서 id 필드 위에 @GeneratedValue(strategy = GenerationType.IDENTITY)라는 어노테이션을 한 줄 적어주었을 뿐이다.

원래 DB에서 직접 설정하던 걸, 이번엔 왜 자바 코드에서 어노테이션으로 짜주는 걸까? Java가 직접 숫자를 카운트해서 올리는 건가? 하는 의문이 들었다.

알고 보니 이 어노테이션에는 JPA의 두 가지 핵심 기능이 작동하고 있었다.

첫째, DB 테이블 자동 생성 (DDL Auto):
내가 DB 콘솔에서 직접 테이블을 만들지 않았음에도, 스프링 부트가 실행될 때 JPA가 이 @GeneratedValue 어노테이션을 읽어 들인다. 그리고 개발자가 이 컬럼을 Auto_Increment(PostgreSQL의 Identity)로 만들고자 한다는 것을 파악한 뒤, Supabase에 DDL 쿼리를 전송해 그 설정 그대로 테이블을 대신 만들어 준다. (즉, 여전히 번호를 올리는 역할은 Java가 아니라 DB가 담당한다.)

둘째, 삽입 후 ID 값 되가져오기 (Identity 매핑):
번호를 매기는 건 DB의 역할이지만, 자바 입장에서도 저장 직후 그 번호를 알아야 한다. 서비스 로직에서 repository.save(결과객체)를 호출해 데이터를 넣는 순간, DB는 새 번호(예: 15번)를 발급해서 DB에 저장한다.
이때 @GeneratedValue(strategy = GenerationType.IDENTITY) 설정이 되어 있으면, JPA는 INSERT 쿼리를 실행한 직후 DB가 생성한 해당 ID 값을 즉시 반환받는다. 그리고 방금 저장한 자바 객체의 id 필드에 그 값을 정확히 채워 넣는다.

결과적으로, 이 어노테이션은 단순히 DB 테이블을 의도대로 자동 생성하기 위한 설계도 역할뿐만 아니라, DB가 자체적으로 생성한 번호를 자바 객체와 오차 없이 동기화하기 위한 역할을 하던 것이다.

3. 습관적인 @Setter 사용이 엔티티 설계에서 지양되는 이유

DTO 클래스를 작성할 때는 데이터 전달의 편의를 위해 @Getter와 @Setter를 습관적으로 함께 선언하곤 했다. 하지만 이번 엔티티 설계에서는 객체의 상태 변경을 엄격히 제한하기 위해 @Setter를 제외하고 생성자만을 활용하여 필드 값을 초기화하도록 구성했다.

가장 큰 이유는 데이터 무결성(Data Integrity) 유지와 예측 가능한 코드를 작성하기 위함이다.

객체의 일관성 보장: 사기 탐지 결과와 같은 데이터는 한 번 생성된 이후 그 상태가 임의로 변경되어서는 안 된다. 만약 @Setter가 열려 있다면, 의도치 않은 곳에서 setStatus()와 같은 메서드를 호출해 값을 변경할 수 있게 된다. 이는 객체가 가진 데이터의 신뢰도를 떨어뜨리는 원인이 된다.

변경 지점의 불분명함: 프로젝트 규모가 커질수록 @Setter를 통해 필드 값을 변경하는 코드가 산재하게 되면, 특정 필드의 값이 언제 어디서 바뀌었는지 추적하기가 매우 어려워진다. 이는 유지보수 시 디버깅 효율을 저하시키는 결정적인 요인이 된다.

따라서 인스턴스화 시점에 생성자를 통해 값을 주입하고, 이후에는 오직 조회만 가능하도록 설계하여 객체의 불변성을 확보했다. 만약 비즈니스 로직상 상태 변경이 반드시 필요하다면, 단순한 set 메서드가 아니라 updateStatus()와 같이 변경의 이유와 목적이 명확히 드러나는 비즈니스 메서드를 별도로 정의하여 관리하는 것이 올바른 설계 방향이다.

4. AI 서버엔 파일을 주면서, DB에는 굳이 imageUrl을 저장하는 이유

로직을 서비스 계층으로 옮기면서 이미지 처리 방식을 다듬었다. 이미지 파일은 Supabase 스토리지에 업로드하고, AI 서버(파이썬)에는 처리 속도를 극대화하기 위해 URL을 타지 않고 이미지 파일 자체를 직접 던져주도록 설계했다.
여기서 딜레마가 왔다. AI 서버한테 어차피 URL을 안 줄 거면, 우리 DB 테이블에 imageUrl이라는 컬럼을 굳이 만들어서 저장할 필요가 있을까?

고민 끝에 내린 결론은 우리 시스템은 투 트랙(Two-Track)으로 돌아가기 때문에 무조건 저장해야 한다였다.

  • 트랙 1 (AI 서버): 실시간 판별이 목적이므로 속도가 생명이다. 그래서 파일을 직접 받는다.
  • 트랙 2 (DB 영구 기록): 여긴 나중에 사람이 확인하는 곳이다. 만약 관리자가 "이 사람 왜 사기 판정이 났지? 증거 사진 좀 보자"라고 했을 때, DB에 URL이 없다면 Supabase에 쌓인 수만 개의 파일 중 어떤 게 그 사진인지 찾을 방법이 없다.

즉, imageUrl은 단순한 문자열이 아니라, DB에 저장된 글자 데이터와 Supabase에 저장된 실제 사진을 연결해 주는 유일한 이정표이기 때문에 절대 빼놓아선 안 되는 핵심 데이터였다.

5. Service에서 try-catch 대신 throws Exception을 사용하는 이유

마지막으로 Controller에 있던 길고 지저분한 try-catch 문을 걷어내고, 비즈니스 로직을 Service로 옮겼다. 그런데 Service 클래스의 메서드 안에서는 직접 try-catch를 쓰지 않고, 메서드 이름 옆에 throws Exception을 붙여서 에러 처리를 호출부로 던져버렸다.

왜 Service가 직접 에러를 처리하지 않고 밖으로 던졌을까?
이는 서비스와 컨트롤러의 역할 분담 때문이다. 만약 서비스가 로직을 수행하다 에러가 났는데 이를 직접 catch해서 내부에서 무마해버리면, 컨트롤러는 에러 발생 여부를 인지하지 못하고 사용자에게 잘못된 성공 응답을 보낼 위험이 있다.
그래서 서비스는 "나 이거 여기서 해결 안 하고, 나를 부른 컨트롤러한테 보고할게!"라고 에러를 전달(throws)하는 것이다. 이렇게 하면 에러를 전달받은 컨트롤러가 최종 판단을 내려 사용자에게 적절한 에러 메시지를 응답할 수 있고, 서비스 코드는 비즈니스 로직에만 집중할 수 있어 훨씬 깔끔해진다.

그럼 왜 하필 IOException이 아니라 Exception일까?
파일을 읽는 코드(file.getBytes())가 포함되어 있다 보니 IDE는 처음에 IOException을 추천한다. 하지만 이 서비스 로직은 단순한 파일 읽기 외에도 Supabase 업로드, 파이썬 AI 서버 통신, DB 데이터 저장 등 여러 단계의 외부 통신을 거친다.
만약 IOException만 던지겠다고 선언해 두면, DB 접속 에러나 네트워크 통신 장애처럼 다른 종류의 예외가 터졌을 때 컨트롤러가 이를 제대로 낚아채지 못해 서버가 비정상적으로 종료될 수 있다. 따라서 어떤 종류의 사고가 터지든 상관없이 안전하게 컨트롤러로 전달하기 위해, 모든 예외의 최상위 클래스인 Exception을 사용하는 것이 이 구조에서는 더 안전한 설계다.

0개의 댓글