기존에 사용하던 Filter는 HTTP 요청 흐름 중에 포워드(foward), 에러 디스패치(error dispatch)가 일어나면 같은 요청이 여러 번 필터 체인을 탈 수 있다.
이런 상황에서 의도치 않게 필터가 두 번 이상 실행되는 것을 방지하기 위해 한 번만 실행하도록 보장해주는 것이 OncePerRequestFilter이다.
기본 Filter 인터페이스의 문제점
포워드 발생 상황
// 단순히 sout을 수행하는 일반 Filter
public class SimpleLoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("[SimpleLoggingFilter] 필터 실행!");
chain.doFilter(request, response);
}
}
@RestController
public class TestController {
@GetMapping("/test")
public String test(HttpServletRequest request) throws ServletException, IOException {
System.out.println("[Controller] /test 요청 처리 중...");
// 내부적으로 다른 URL로 forward 시도
request.getRequestDispatcher("/forwarded").forward(request, response);
return "처리 완료";
}
@GetMapping("/forwarded")
public String forwarded() {
System.out.println("[Controller] /forwarded 요청 처리 중...");
return "포워딩된 페이지";
}
}
1. Filter를 거쳐서 TestController의 test 메서드 실행
2. test 메서드 내부에서 /forwarded URL로 포워드
3. 서버 내부에서 다시 필터 체인을 실행
-> `[SimpleLoggingFilter] 필터 실행!` 이 두 번 출력됨
에러 디스패치 발생 상황
@RestController
public class TestController {
@GetMapping("/error-test")
public String errorTest() {
System.out.println("[Controller] /error-test 요청 처리 중...");
// 일부러 예외 발생시키기
throw new RuntimeException("일부러 터뜨린 에러!");
}
@RequestMapping("/error")
public String error() {
System.out.println("[Controller] 기본 에러 페이지 처리 중...");
return "에러가 발생했습니다.";
}
}
1. errorTest 메서드에서 에러가 발생
2. 서블릿 컨테이너가 /error로 다시 디스패치
(/error는 스프링부트가 기본적으로 설정하는 에러 처리용 경로)
3. 서버 내부에서 다시 필터 체인을 실행
-> `[SimpleLoggingFilter] 필터 실행!` 이 두 번 출력됨
에러 처리용 경로는 설정파일(application.properties)에서 설정 가능 server.error.path=/my-error (/my-error로 에러 처리용 경로 변경)
ExceptionHandler와 에러 처리용 경로(ErrorController) 차이
*둘 다 구현한 경우 ExceptionHandler가 우선 적용되고, 해당하는 Handler가 없으면 ErrorController 적용.
OncePerRequestFilter 사용 예시
스프링 시큐리티에서 커스텀 인증 필터를 만들 때 많이 사용된다.
JWT 토큰 검증 필터
사용자 인증 정보 세팅 필터
요청 헤더 검사 필터
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (token != null && validateToken(token)) {
// 토큰이 유효하면 SecurityContext에 인증 정보 저장
Authentication auth = createAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
// 필수: 다음 필터로 넘기기
filterChain.doFilter(request, response);
}
}
- @Configuration, @ComponentScan 또는 XML 파일 설정을 통해 bean 정보를 읽는다.
2. Bean 인스턴스 생성
- 기본 생성자가 호출되어 객체가 생성되며, 싱글톤 bean은 앱이 시작할 때 미리 생성된다.
- 프로토타입 bean은 요청 시점에 생성된다.
3. 의존성 주입
- 생성된 bean에 의존하는 다른 bean이 있으면 의존성 주입
4. 초기화 콜백
- @PostConstruct 또는 InitializingBean의 afterPropertiesSet() 메서드 호출
- DB연결, 리소스 준비, 설정 작업 등의 준비 수행
5. Bean 사용
- 비즈니스 로직 수행
6. 소멸 전 콜백
- @PostDestroy 또는 DisposableBean의 destroy() 메서드 호출
- 파일 닫기, 데이터베이스 연결 해제 등 리소스 정리
7. 스프링 종료(Bean 소멸)
@Bean을 사용한 생명주기 콜백
설정 정보에 초기화 메서드와 종료 메서드를 지정하여 생명주기 콜백을 지원한다.
이 방법으로 외부 라이브러리에도 콜백 메서드를 사용할 수 있다.
public class MyBeanV2 { // 비즈니스 로직 실행 전후로 동작시킬 Bean 정의
private String data;
public MyBeanV2() {
System.out.println("Bean 생성자 호출");
System.out.println("data = " + data);
}
public void setData(String data) {
this.data = data;
}
public String getData() {
return data;
}
public void init() {
System.out.println("MyBean 초기화 - init() 호출됨");
System.out.println("data = " + data);
}
public void close() {
System.out.println("MyBean 종료 - close() 호출됨");
data = null;
}
public void doSomething() {
System.out.println("MyBean 작업 중...");
}
}
@Configuration // 설정 클래스에서 bean 등록
public class AppConfigV2 {
// initMethod: 빈 생성 후 호출할 메서드명
// destroyMethod: 컨테이너 종료 시 호출할 메서드명
@Bean(initMethod = "init", destroyMethod = "close")
public MyBeanV2 myBeanV2() {
MyBeanV2 myBeanV2 = new MyBeanV2();
// 의존관계 설정
myBeanV2.setData("Example");
return myBeanV2;
}
}
public class InterfaceCallbackV2 {
public static void main(String[] args) {
// Spring 컨테이너 생성 및 설정 클래스 등록
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigV2.class);
// Bean 사용
MyBeanV2 myBeanV2 = context.getBean(MyBeanV2.class);
myBeanV2.doSomething();
// Bean 소멸 전 필드
System.out.println(myBeanV2.getData());
// 컨테이너 종료 (destroy 호출)
context.close();
// Bean 소멸 후 필드
System.out.println(myBeanV2.getData());
}
}
@PostConstruct, @PreDestroy을 사용한 생명주기 콜백
@PostConstruct : bean 생성 및 의존성 주입 후 호출될 메서드 지정
@PreDestroy : bean이 소멸되기 직전 호출될 메서드 지정
외부 라이브러리 적용 불가
public class MyBeanV3 {
private String data;
public MyBeanV3() {
System.out.println("Bean 생성자 호출");
System.out.println("data = " + data);
}
public void setData(String data) {
this.data = data;
}
public String getData() {
return data;
}
// 초기화 메서드
@PostConstruct
public void init() {
System.out.println("MyBean 초기화 - init() 호출됨");
System.out.println("data = " + data);
}
// 소멸 메서드
@PreDestroy
public void destroy() throws Exception {
System.out.println("MyBean 종료 - destroy() 호출됨");
data = null;
}
public void doSomething() {
System.out.println("MyBean 작업 중...");
}
}
// 해당 클래스 외에는 동일
주요 Bean Scope
Bean의 생명주기 설정 (유지 기간, 재사용 여부 등)
1. Singleton(Default)
- Application 전체에서 공유되는 Bean
2. Prototype
- 매번 새로운 인스턴스가 필요한 경우
3. Request
- Web Application에서 요청별로 별도의 Bean이 필요한 경우
- 요청 데이터를 처리하는 객체
// 자동 등록
@Scope("singleton")
@Component // @Service 사용 가능
public class MemberServiceImpl implements MemberService { ... }
// 수동 등록
@Configuration
public class AppConfig {
@Scope("singleton")
@Bean
public MemberService memberService() {
return new MemberServiceImpl();
}
}
문자열로 구성된 리스트 strings와, 정수 n이 주어졌을 때, 각 문자열의 인덱스 n번째 글자를 기준으로 오름차순 정렬하려 합니다. 예를 들어 strings가 ["sun", "bed", "car"]이고 n이 1이면 각 단어의 인덱스 1의 문자 "u", "e", "a"로 strings를 정렬합니다.
제한 조건
strings는 길이 1 이상, 50이하인 배열입니다.
strings의 원소는 소문자 알파벳으로 이루어져 있습니다.
strings의 원소는 길이 1 이상, 100이하인 문자열입니다.
모든 strings의 원소의 길이는 n보다 큽니다.
인덱스 1의 문자가 같은 문자열이 여럿 일 경우, 사전순으로 앞선 문자열이 앞쪽에 위치합니다.
1차 코드
import java.util.Arrays;
class Solution {
public String[] solution(String[] strings, int n) {
Arrays.sort(strings, n, n+1);
return strings;
}
}
처음에는 Arrays.sort() 메서드가 각 배열 원소에 대해 특정 위치 인덱스를 기준으로 비교해서 정렬해주는 메서드인 줄 알고 위와 같이 코드를 작성했다.
하지만 결과값이 아래처럼 변화없이 출력되길래 찾아보니, 배열 자체의 인덱스를 설정하는 파라미터였다.
내 코드는 결국 n번째 원소 하나를 정렬하려고 시도하니까 아무 의미 없이 그대로 결과값이 반환되는 것이었다.
1에 해당하는 엔티티가 연관관계의 주인이 되어 FK를 관리해야하는 요구사항이 있다고 가정하자.
하지만 DB 입장에서는 N에 해당하는 테이블이 FK를 관리한다.
따라서 객체지향과 데이터베이스의 충돌로 인해, 1에 해당하는 엔티티가 N에 해당하는 테이블의 데이터를 수정하게 된다.
어차피 DB 측에서는 1 : N이어도 N : 1처럼 구조가 만들어지고 동작하기 때문에 기능적으로 큰 차이는 없지만,
요구사항에서 튜터를 먼저 생성하고 튜터의 회사를 관리할 것인지, 회사를 먼저 만들고 튜터를 집어넣어줄 것인지에 따라 1 : N을 사용할지, N : 1을 사용할지가 달라진다.
@OneToMany
@JoinColumn(name = "company_id") // 연관 관계 주인이 JoinColumn으로 관계를 정의
private List<Tutor> tutors = new ArrayList<>();
// Company를 통해 Tutor 테이블에 데이터 추가
company.getTutors().add(tutor);
em.persist(company);
만약 @JoinColumn을 생략하면? JPA가 자체적으로 중간 테이블을 생성하여 관리한다. 불필요한 중간테이블을 생성하고 쿼리를 실행하지 않도록, @JoinColumn을 꼭 붙여줘야 한다.
반대로 둘 다 @JoinColumn을 사용하면? 에러가 나거나 연관관계가 꼬일 위험이 있으므로 반드시 한쪽만 주인으로 정하도록 한다.
1 : N 양방향
1 : N 단방향과 마찬가지로 N:1 양방향을 사용하는 것을 권장한다.
JPA는 양방향 연관관계가 있을 때 DB의 외래키를 주인 엔티티의 필드로 관리한다.
주인이 아닌 엔티티의 필드는 단순히 주인 엔티티가 가지고 있는 값을 매핑받을 뿐이지, DB에 접근해서 값을 생성하거나 수정할 수 없다.
따라서 주인이 아닌 엔티티를 통해 값을 생성하려고 하면, null로 처리한다.
@Entity
public class Member {
@ManyToOne
@JoinColumn(name = "team_id") // 주인: 여기서 team_id FK 컬럼이 생김
private Team team;
}
@Entity
public class Team {
@OneToMany(mappedBy = "team") // 단순히 매핑된 것
private List<Member> members = new ArrayList<>();
}
N : 1 or 1 : N 관계에서는 N인 쪽이 FK를 가지고 있어야 하므로 N이 주인이 된다.
만약 요구사항에 의해 1인 쪽이 연관관계의 주인이 되어야 한다면, 아래와 같이 N인 쪽을 읽기 전용으로 만들고 1인 쪽을 @JoinColumn으로 주인으로 직접 설정해준다.
@Entity
public class Tutor {
@ManyToOne
@JoinColumn(name = "company_id", insertable = false, updatable = false) // 읽기 전용
private Company company;
@Column(name = "company_id")
private Long companyId; // 실제로 값을 세팅하는 필드
}
@Entity
public class Company {
@OneToMany
@JoinColumn(name = "company_id") // FK를 여기가 직접 관리
private List<Tutor> tutors = new ArrayList<>();
}
1 : 1 양방향
1 : N 양방향과 마찬가지로 두 엔티티 중 하나를 주인 엔티티로 설정해야 한다.
주인이 아닌 엔티티는 읽기 전용으로 동작하게 된다.
1 : 1 관계는 서로가 하나만을 참조해야 진정한 일대일 관계지만, @OneToOne으로 명시를 하더라도 동작 자체는 @ManyToOne과 비슷하게 동작한다.
따라서 확실하게 일대일 관계를 보장하기 위해 unique=true를 설정해 주어야 한다.
따라서 일대일 연관관계를 다대일로 변경하고 싶으면 unique 설정만 지워주면 된다.
1:1 관계에서는 어떤 엔티티가 주인이 되어야 할까?
- 자주 조회하거나 수정되는 엔티티
- 생명주기 관리 기준 (ex. profile이 항상 user에 종속된다면 user가 주인)
- DB 설계에 따라 (이미 DB 설계가 어느 테이블에 줄지 결정되어 있다면 거기에 맞춰서 결정)
외래 키 위치의 장단점 정리
1. 주 테이블에 외래 키 위치 장점 - JPA로 객체 지향적 개발이 가능 - 주 테이블만 조회해도 대상 테이블 조회 가능 단점 - 대상 테이블에 값이 없다면 null 허용 (무결성 보장 X) - 대상 테이블의 데이터를 참조하기 때문에, 삭제될 때 외래 키 값을 처리하는 관리 필요
2. 대상 테이블에 외래 키 위치 장점 - 데이터베이스 무결성 보장 - 주 테이블과 대상 테이블 연관관계가 변경되어도 테이블 구조 유지 단점 - 조회 성능 떨어짐 - 연관관계 매칭 설정 복잡 - 지연 로딩으로 설정해도 즉시 로딩된다. (외래키가 대상테이블에 있으면 추후 쿼리로 대상 테이블을 찾아내기 번거롭기 때문)
N : M 단방향
@Entity
@Table(name = "language")
public class Language {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Entity
@Table(name = "tutor")
public class Tutor {
...
@ManyToMany
@JoinTable( // 중간 테이블 생성
name = "tutor_language", // 중간 테이블 이름 설정
joinColumns = @JoinColumn(name = "tutor_id"),
inverseJoinColumns = @JoinColumn(name = "language_id") // 중간테이블을 연결할 상대 엔티티 명시
)
private List<Language> languages = new ArrayList<>();
...
}
N : M 양방향
@Entity
@Table(name = "language")
public class Language {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "languages")
private List<Tutor> tutors = new ArrayList<>();
}
N : M 매핑의 문제점
level, license와 같은 중간테이블에 추가적인 데이터가 필요하다.
중간테이블이 내부적으로 생성되기 때문에 생각하지 못한 SQL Query가 실행된다.
중간 테이블을 실제 엔티티로 만들어서 @ManyToOne, @OneToMany로 관리한다.
두 테이블의 id를 복합키로 PK가 생성된다.
PK가 종속적이면 사이드 이펙트가 발생하므로, 비즈니스적으로 의미없는 Long값으로 PK를 설정하는 게 유연성에 좋다.
N : M 매핑 - 중간테이블 생성
중간 테이블 엔티티를 직접 생성하여 N : M 매핑의 문제점을 해결한다.
@Entity
@Table(name = "tutor_language")
public class TutorLanguage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "tutor_id")
private Tutor tutor;
@ManyToOne
@JoinColumn(name = "language_id")
private Language language;
private Integer level;
private String license;
}
@Entity
@Table(name = "language")
public class Language {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "language")
private List<TutorLanguage> tutorLanguages = new ArrayList<>();
}
@Entity
@Table(name = "tutor")
public class Tutor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(insertable = false, updatable = false)
private Company company;
@OneToOne
@JoinColumn(name = "address_id", unique = true)
private Address address;
@OneToMany(mappedBy = "tutor")
private List<TutorLanguage> tutorLanguages = new ArrayList<>();
public Tutor() {
}
public Tutor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
그럼 가져온 엔티티를 재사용하는 게 아니라, 지연 로딩으로 인해반복문을 돌 때마다 DB에 접근하여 데이터를 조회하게 된다.
이 문제를 N+1 문제라고 한다.
처음 DB 조회한 횟수 1과 반복문을 돌 때마다 N번 DB 조회를 실행해서 N+1이다.
N+1 문제를 해결하기 위해 fetch join을 사용해서 연관된 엔티티의 데이터까지 한 번에 조회하는 방법이 사용된다.
하지만 fetch join의 문제는 처음에 말했다시피 데이터 증폭이 발생할 수 있고, 그로 인해서 페이징도 제대로 적용되지 않을 가능성이 크다.
fetch join 대신 배치 사이즈
사실상 배치 사이즈(@BatchSize)를 사용해서 N+1 문제를 해결하는 게 가장 간단한 방법이다.
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 10) // 한 번에 10개씩 로딩
private Team team;
// 또는 application.properties에서 hibernate.jdbc.batch_size=10으로 설정
이렇게 10으로 값을 설정하면, 해당 데이터에 대해 10번의 DB 접근 요청이 발생해야 DB에 접근하여 쿼리를 실행한다.
배치 사이즈를 사용하면 데이터 증폭이 발생하지 않기 때문에 페이징도 적용이 가능하다.
해당 쿼리가 어느정도 발생해야 DB 요청을 실행하기 때문에 성능 최적화에 적절한 방법이다.
그렇다면 어떤 상황에서 fetch join 또는 배치 사이즈를 사용해야 할까?
데이터를 조회하는 과정에서 연관된 데이터가 많다면 배치 사이즈를 사용
적은 양의 데이터를 빠르게 조회해서 처리해야 하는 상황에서는 fetch join을 사용
탈 JPA?
사실상 데이터 증폭, N+1 문제는 JPA(연관관계 설정, 지연 로딩)에 의해 발생하는 문제다.
이 문제를 방지하기 위해 아예 연관 관계를 두지 않는 DB 설계도 있다.
댓글과 게시글처럼 연관성이 짙은 경우를 제외하면 굳이 서로 연관 관계를 설정해서 동일한 생명주기를 가질 필요가 없기 때문이다.
(이런 상황에서는 nativeQuery를 사용해서 DB에서 데이터를 조회한다.)
대기업처럼 큰 데이터를 관리하는 상황에서는 이렇게 연관성이 큰 데이터를 다룰 일이 많지 않기 때문에 nativeQuery를 사용하는 경우가 대부분이고 JPA를 잘 사용하지 않지만, 지금의 내가 하는 프로젝트나 과제는 규모가 작으면서 각 엔티티가 서로 연관성을 가지기 때문에 JPA를 사용하는 게 적절하다.
- HandlerMethodReturnValueHandler (ReturnValueHandler의 실제 인터페이스)
- 컨트롤러가 반환하는 값을 HTTP 응답 본문에 적절히 담아 전송한다.
-HttpMessageConverter를 호출해서 데이터를 적절한 타입으로 변환한다.
Converter 구현
특정 엔티티 포맷의 객체로 값을 전달받고자, 일반적인 자료형이 아닌 특별한 타입으로 변환해주는 컨버터가 필요할 때는 직접 컨버터를 생성해야 한다.
@ModelAttribute를 사용하면 컨버터가 없어서 객체에 바로 저장할 수 있었는데 왜 컨버터를 생성해야 할까? ModelAttribute는 ?name=채진&age=25 처럼 어떤 필드가 전달되는지 명확하기 때문에 바로 객체에 저장이 가능하다.
컨버터를 생성해서 사용해야 하는 경우는 아래처럼 특수한 형태로 데이터가 저장되어 바로 필드에 저장하기 어려운 상황이다. ?person=wonuk:1200 ?code=dsafkjf@code (<- @앞까지의 데이터만 DB에 저장할 가치가 있는 경우)
1. 특정 상황에 맞는 컨버터를 생성
public class StringToPersonConverter implements Converter<String, Person> {
// source = "wonuk:1200"
@Override
public Person convert(String source) {
// ':' 를 구분자로 나누어 배열로 만든다.
String[] parts = source.split(":");
// 첫번째 배열은 이름이다. -> wonuk
String name = parts[0];
// 두번째 배열은 개월수이다. -> 1200
int months = Integer.parseInt(parts[1]);
// 개월수 나누기 12로 나이를 구하는 로직 (12개월 단위만 고려)
int age = months / 12;
return new Person(name, age);
}
}
2. 컨버터 등록(WebMvcConfigurer의 메서드 오버라이딩)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry){
registry.addConverter(new StringToPersonConverter());
registry.addConverter(new PersonToStringConverter());
}
}
기존에는 생성한 컨버터를 직접 ConversionService 객체를 생성한 뒤에 집어넣어줘야 했지만, 스프링에서 제공하는 ConversionService인 WebMvcConfigurer의 addFormatter 메서드를 사용하면 간편하다.
그리고 원래는 컨버터 등록(생성), 컨버터 사용 모두 ConversionRegistry와 ConversionService를 직접 사용해야 해서 개발자가 내부 로직으로 직접 데이터를 변환하는 것과 큰 차이가 없다.
하지만 스프링의 경우는 파라미터로 값을 전달받을 때 알아서 등록된 컨버터를 사용하고 데이터를 바인딩해주기 때문에 컨버터를 사용할 때의 코드 작성은 필요 없다.
Formatter 구현
포맷터는 주로 사용자 지정 포맷을 적용해 데이터를 변환할 때 사용한다.
객체를 특정한 포맷에 맞춰서 문자로 출력하는 기능에 특화된 것이 formatter이다. (converter보다 조금 더 세부적인 기능)
1. 포맷터 생성
@Slf4j
public class PriceFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text = {}, locale = {}", text, locale);
// 변환 로직
// NumberFormat이 제공하는 기능
NumberFormat format = NumberFormat.getInstance(locale);
// "10,000" -> 10000L
return format.parse(text);
}
@Override
public String print(Number object, Locale locale) {
log.info("object = {}, locale = {}", object, locale);
// 10000L -> "10,000"
return NumberFormat.getInstance
Locale
지역 및 언어 정보를 나타내는 객체.
언어코드 en, ko
국가코드 US, KR
2. 포맷터 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry){
registry.addConverter(new StringToPersonConverter());
registry.addConverter(new PersonToStringConverter());
// Formatter 등록
conversionService.addFormatter(new PriceFormatter());
}
}
결론
스프링 덕에 간소화된 버전으로 컨버터와 포맷터를 등록하고 사용할 수 있지만, 되도록 기존에 스프링이 제공하는 컨버터와 포맷터를 활용하는 게 베스트