ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)
하나씩 알아보자.
SRP (Single Responsibility Principle)
단일 책임 원칙이라는 뜻의 SRP. 무슨 말일까? 한 클래스는 하나의 책임만 가져야 한다.
하나의 책임이라는 것은 모호하다. 책임의 크기를 말하는게 아니다. 문맥과 상황에 따라서도 다르다.
중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것.
예를 들어, MVC 패턴에서 V는 View이다. UI와 관련된 책임을 가지는 부분이고 이 곳에선 비즈니스 로직이나 그 외 것들엔 책임을 지게 해선 안된다(안되는게 좋다).
개인적인 생각으로 JSP가 대체 기술이 생긴 이유는 이 단일 책임 원칙이 잘 지켜지지 않기 때문이다.
JSP는 뷰와 로직을 동시에 가지고 있다. 책임이 너무 크고 분산되어 있지 않다. 이를 대체하기 위해 스프링 MVC 패턴이 등장했다.
OCP (Open/Closed Principle)
개방-폐쇄 원칙이라고 하는데 난 확장에는 열려있고 변경에는 닫혀있는 원칙이라고 표현한다.
즉, 기술의 확장이 있어도 코드의 변경이 없어야 한다는 뜻인데 굉장히 알 수 없는 말이다.
무엇을 떠올리면 되냐면 DI를 떠올리면 된다. 클라이언트 코드는 그저 인터페이스만을 의존한다. 인터페이스를 구현한 기술이 10개든 100개든 어떤 기술이 새로 만들어지고 어떤 기술로 갈아끼우던지 클라이언트 코드는 변경할 부분이 없다. 인터페이스를 의존하기 때문이다.
이게 OCP 원칙이고 5가지 원칙 중 가장 중요한 원칙이라고 생각한다. 의존관계의 결정을 나중으로 미루는 것. 그에 따라 확장성과 유지보수에 훨씬 유리해지고 코드의 변경은 최소화하거나 아예 하지 않아도 된다.
LSP (Liskov Substitution Principle)
리스코프 치환 원칙이라고 하는데 제일 어려워 보이지만 제일 쉬운 원칙이다.
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
"무슨 말이세요?"
그러니까, 쉽게 말해서 자동차라는 인터페이스가 있고 엑셀밟기 라는 메서드를 정의했으면 이 인터페이스를 구현하는 구현체는 엑셀밟기 메서드를 구현할 때 앞으로 가는 기능을 구현해야 하지 뒤로 가는 기능을 구현해선 안된다는 뜻이다. 프로그램이 의도하는 정확성을 지켜야 한다는 뜻이다. 근데 스포츠카와 기본 자동차는 같은 엑셀을 밟더라도 속도의 차이가 현저히 날 수 있다. 그러나 둘 다 엑셀을 밟으면? 앞으로 간다. 이게 포인트.
그 구현하는 방식이 조금씩 다르더라도 정확한 의도대로 구현해야 한다는 원칙.
ISP (Interface Segregation Principle)
인터페이스 분리 원칙. 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
예를 들어, 자동차 인터페이스가 있을 때 이 인터페이스는 운전과 관련된 메서드와 정비와 관련된 메서드 둘 다를 정의할 수 있지만 애시당초에 자동차 인터페이스를 운전 인터페이스, 정비 인터페이스로 분리하는게 더 좋다는 것이다.
왜 더 좋을까?
첫번째는, 저 둘로 분리하게 됐을 때 정비 인터페이스 자체가 변해도 운전자 클라이언트 - 운전 인터페이스 간에는 어떠한 영향도 주지 않는다.
두번째는, 인터페이스가 명확해지고 대체 가능성이 높아진다.
DIP (Dependency Inversion Principle)
의존관계 역전 원칙으로, OCP와 같이 중요한 원칙 중 하나라고 생각한다. 프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다." 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
쉽게 이야기하면, 클라이언트 코드는 구체클래스를 의존하는게 아니라 인터페이스를 의존해서 기술에 변경이 있어도 클라이언트 코드에 수정이 필요없게 설계하라는 뜻이다. 이 말을 한 단어로 DI라고 하는 것이고. 의존관계의 결정을 나중으로 미루는 것이다.
모바일 앱의 등장이 주 원인이라고 할 수 있다. 모바일 앱을 추후에 만든다고 해도 통합된 인증 체계로부터 얻는 이점이 분명히 있다고 생각했다.
모바일 앱을 사용할 땐 주로 토큰 기반 인증 방식을 사용한다. 그 이유는 다음과 같다.
장기 세션 유지의 어려움: 모바일 디바이스는 종종 네트워크 연결 상태가 변할 수 있고, 앱이 백그라운드에서 종료되거나 장치가 재부팅될 수 있다. 세션은 이런 환경에서는 연결이 끊기기 쉬워 토큰 방식이 더 안정적인 경우가 많다.
스케일러빌리티: 세션 정보를 서버에서 관리해야 할 경우, 사용자가 많아질수록 서버의 부담이 커질 수 있다. 반면 토큰은 클라이언트 측에서 관리되기 때문에 서버는 인증을 확인만 하면 되므로 부하가 줄어든다.
다양한 플랫폼 지원: 모바일 앱뿐만 아니라 웹사이트, 다른 종류의 클라이언트에서도 동일한 방식으로 인증 시스템을 구현할 수 있다. 이는 통합된 인증 체계를 유지하기에 유리하다.
이러한 이유들이 내가 인증 방식을 JWT로 구현하고 싶어지게 했다.
우선, 개발 환경은 다음과 같다.
Spring Boot 3.2.2
Java 21
Spring Security 3.2.2
io.jsonwebtoken:jjwt 0.12.3
MySQL 8
로그인 흐름
유저가 /login 으로 username, password를 같이 보내면 스프링 시큐리티 내부적으로는 UsernamePasswordAuthenticationFilter라는 필터를 통해 해당 유저가 현재 Database에 존재하는 유저인지 찾고 그 유저의 로그인 정보(ID, PW)가 일치하는지 확인해서 맞다면 JWT토큰을 만들어서 유저에게 반환한다. (그림에서는 JWT 토큰 반환은 생략)
인증과 인가의 차이가 뭔가요?
인증(Authentication): 인증은 사용자가 누구인지 확인하는 과정. 사용자가 시스템에 로그인할 때 아이디와 비밀번호를 제공하는 것이 대표적인 예. 스프링 시큐리티에서는 AuthenticationManager가 이 역할을 담당하고, 사용자의 신원을 확인한 후 'Authentication' 객체에 이 정보를 저장한다.
인가(Authorization): 인가는 인증된 사용자가 특정 자원에 접근하거나 특정 작업을 수행할 수 있는 권한을 가지고 있는지 확인하는 과정. 예를 들어 어떤 사용자가 특정 페이지에 접근하거나 데이터를 수정할 권한이 있는지 검사하는 것
Unauthorization과 Forbidden의 차이는요?
Unauthorization(401): 인증이 실패했을 때 반환. 아이디나 비밀번호가 잘못됐거나 아예 제공되지 않았을 때. 즉, 사용자가 누구인지 시스템이 식별하지 못했을 때 발생
Forbidden(403): 사용자 인증은 성공적으로 마쳤지만, 요청한 자원에 대한 접근 권한이 없을 때 반환. 예를 들어, 사용자가 로그인은 했지만 관리자 페이지에 접근하려 할 때 해당 페이지에 대한 접근 권한이 없는 경우 발생.
정리하자면, "Unauthorization은 당신이 누구인지 모르겠으니 로그인이 필요합니다"라는 의미이고, Forbidden은 "당신이 누구인지 알겠지만, 이 작업을 수행할 권한이 없습니다"라는 의미이다.
import kr.co.tbell.mm.dto.administrator.*;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.repository.administrator.AdministratorRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import javax.management.InstanceAlreadyExistsException;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AdministratorServiceImpl implements AdministratorService, UserDetailsService {
private final AdministratorRepository administratorRepository;
private final PasswordEncoder passwordEncoder;
@Override
public ResCreateAdministrator createAdministrator(ReqCreateAdministrator reqCreateAdministrator)
throws InstanceAlreadyExistsException {
Optional<Administrator> adminOptional =
administratorRepository.findByUsername(reqCreateAdministrator.getUsername());
if (adminOptional.isPresent()) {
throw new InstanceAlreadyExistsException("Admin already exists with username: "
+ reqCreateAdministrator.getUsername());
}
Administrator admin = Administrator
.builder()
.username(reqCreateAdministrator.getUsername())
.password(passwordEncoder.encode(reqCreateAdministrator.getPassword()))
.role(Role.ROLE_ADMIN)
.build();
Administrator savedAdmin = administratorRepository.save(admin);
return new ResCreateAdministrator(savedAdmin.getId(), savedAdmin.getUsername(), savedAdmin.getRole());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Administrator> byUsername = administratorRepository.findByUsername(username);
if (byUsername.isEmpty()) {
throw new UsernameNotFoundException(username);
}
return new CustomAdministratorDetails(byUsername.get());
}
}
이 AdministratorServiceImpl에서는 두개의 인터페이스를 구현한다. AdministratorSerivce, UserDetailsService.
이 UserDetailsService는 스프링 시큐리티에서 회원 인증을 위해 구현해야 하는 인터페이스다. 이 인터페이스를 구현해서 데이터베이스로부터 특정 'Username'을 가진 유저가 있는지 찾아, 있다면 UserDetails 라는 인터페이스 타입의 클래스를 반환한다. UserDetails도 마찬가지로 직접 구현해야한다.
import jakarta.servlet.http.HttpServletRequest;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.jwt.JwtFilter;
import kr.co.tbell.mm.jwt.JwtManager;
import kr.co.tbell.mm.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.Collections;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtManager jwtManager;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// JWT 방식을 사용하기 때문에 CSRF 공격에 대한 위험성 X
http.csrf(AbstractHttpConfigurer::disable);
// JWT 방식을 사용하기 때문에 FormLogin, Basic 방식을 Disable
http.formLogin(AbstractHttpConfigurer::disable);
http.httpBasic(AbstractHttpConfigurer::disable);
// 한개의 아이디에 대해 최대 중복 로그인 개수 maximumSessions
// maxSessionPreventsLogin 다중 로그인 개수를 초과했을 때 처리방법. true: 새로운 로그인 차단, false: 기존 세션 하나 삭제
// JWT 방식을 사용하기 때문에 SessionCreationPolicy STATELESS
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// AntPathRequestMatcher 객체를 사용한 이유는 개인적으로 가시성이 좀 더 좋고 어떤 Http Method인지 바로 알 수 있어서 사용
// 굳이 안 사용해도 된다.
http.authorizeHttpRequests(request ->
request.requestMatchers(
new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
new AntPathRequestMatcher("/login", "POST")
).permitAll()
.anyRequest().hasRole("ADMIN"));
// LoginFilter 앞에 JwtFilter 등록
http.addFilterBefore(new JwtFilter(jwtManager), LoginFilter.class);
// addFilterAt은 정확히 그 필터(UsernamePasswordAuthenticationFilter)를 내가 만든 LoginFilter로 대체하겠다는 메서드.
// addFilterBefore, addFilterAfter 이 것들은 말 그대로 그 전 또는 그 후에 붙이겠다는 의미
http.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), jwtManager),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
위 코드에서 URL Path에 따라 인증이 필요한지 또는 특정 Role을 가진 유저인지 확인하는 다음 코드를 보자.
http.authorizeHttpRequests(request ->
request.requestMatchers(
new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
new AntPathRequestMatcher("/login", "POST")
).permitAll()
.anyRequest().hasRole("ADMIN"));
이 코드는 "/api/v1/admin/signup", "/login"으로의 요청은 인증이 필요없이 모두 허가한다는 의미이고, 그 외(anyRequest())는 모두 ADMIN Role이 있어야 접근 가능하다는 설정이다.
스프링 공부를 하면서 가장 자주 사용되며 단순한 그렇지만 중요한 구조에 대해 알아보려 한다.
그냥 내가 공부하고 코드를 작성할 때 유의하며 작성하면 더 좋은 구조가 될 것 같아 스스로 학습하기 위해 작성한다.
SMALL
가장 단순한 구조는 역할에 따라 3가지 계층으로 나누는 것이다.
프레젠테이션 계층
UI 관련 처리 담당
웹 요청과 응답
사용자 요청을 검증
주 사용 기술: 서블릿과 HTTP같은 웹 기술, 스프링 MVC
서비스 계층
비즈니스 로직을 담당
주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
데이터 접근 계층
실제 데이터베이스에 접근하는 코드
주 사용 기술: JDBC, JPA, Redis, Mongo,...
순수한 서비스 계층이란?
서비스 계층은 가급적 특정 기술을 의존하지 말아야 한다는 말은 어떤 의미일까?
시간이 흘러 웹(UI)과 관련된 기술이 변하고, 데이터 저장 기술을 다른 기술로 변경할 여지는 충분하다. 지금 사용하는 기술보다 더 좋은 기술이 탄생한다던가, 지금 사용하고 있는 기술이 요구사항을 만족할 수 없는 기술이라면 사용하다가도 변경해야 한다.
그러나, 그렇다 한들 비즈니스 로직은 사용하는 기술이 바뀐다고 변경하지 않는다. 서비스가 다루는 기능이 변하거나 새로운 기능이 추가, 변경되어야 하는 경우가 아니면 비즈니스 요구사항이 변경될 리 없고 그 요구사항을 다루는 로직도 기술에 의존해서 변경되면 안 된다. (물론 이상적인 경우일 때를 말한다)
그렇기 때문에 서비스 계층은 가급적 특정 기술에 의존하면 안 된다는 뜻이다. 그래서 서비스 계층에 대해 코드를 작성할 때도 이 점을 유의하면서 작성해야 한다. 예를 들어, 프레젠테이션 계층은 클라이언트가 접근하는 UI와 관련된 기술인 웹, 서블릿, HTTP와 관련된 부분을 담당해 준다. 그래서 서비스 계층을 이런 UI와 관련된 기술로부터 보호해 준다. 그 결과로, REST API를 사용하다가도 GraphQL로 변경하려 할 때 프레젠테이션 계층의 코드만 변경하고 서비스 계층은 변경하지 않아도 된다. 데이터 접근 계층은 데이터를 저장하고 관리하는 기술을 담당해 주고 그렇기에 JDBC, JPA와 같은 구체적인 데이터 접근 기술로부터 서비스 계층을 보호해 준다. 그 결과로, JDBC를 사용하다가 JPA로 변경하더라도 서비스 계층은 변경하지 않아도 된다. 왜냐하면 서비스 계층은 데이터 접근 계층에 직접 접근하는 게 아니라 데이터 접근 계층이 인터페이스를 제공하고 서비스 계층은 이 인터페이스에만 의존하면 되기 때문이다.
이로 인해 파생되는 긍정적인 부분은 유지보수가 수월해지고 테스트도 편리해진다.
이러한 이유로 인해 잘 만들어진 구조는 인터페이스와 그 인터페이스를 구현한 구현체가 나뉘는 것이고 사용자는 구현체가 어떤 식으로 구현했는지를 세세히 알 필요 없이 인터페이스를 가져다가 사용만 하면 되는 것이다. (예: DataSource 인터페이스와 이 인터페이스를 구현한 여러 가지 구현체(HikariDataSource, DriverManagerDataSource,...) 이것을 추상화라고 한다.
정리하자면..
구조에 정답이 있는 것은 아니지만, 더 나은 구조에 가까울수록 변경이 있을 때 변경에 대한 제약이 적다. 즉, 서비스 계층은 가급적 비즈니스 로직만 구현하고(순수 자바 코드로) 특정 구현 기술에 의존을 가급적 지양해야 한다. 그래야 이후 사용하는 기술을 변경하더라도 서비스 계층에 변경 영향 범위를 최소화할 수 있기 때문이다.
서비스 계층에서 특정 기술에 의존하고 이 서비스 계층에 사용되는 비즈니스 로직이 별로 없으면 크게 문제가 될 것이 없지만, 만약 그 코드가 수만 줄 수십만 줄이라면 기술하나를 바꿨는데 수십만줄이 모두 변경되어야 한다. 그렇게 변경을 하면? 아주 매우 엄청 높은 확률로 사이드 이펙트가 발생할 것 같다..
JPA와 Spring을 공부하던 중 REST API를 통해 데이터를 주고 받아야하는 상황이 빈번하게 있을텐데 이 때 컨트롤러에서 엔티티를 반환하는 일이 생기면 절대 안된다. 많은 문제가 있지만, 그 중에서도 어떤 문제가 있냐면, 엔티티의 변경이 생겼을 때 API 스펙까지도 아예 변경되어야 하기 때문이다. 또한, 엔티티는 지연 로딩 전략을 사용할텐데 지연 로딩 전략을 사용한 상태에서 이를 처리하지 않는 경우 Response를 제대로 할 수 없거나 성능에 문제가 생기거나 불필요한 Response data가 생긴다.
예시를 살펴보자, 우선 컨트롤러가 엔티티를 다루는 코드를 살펴보자.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/")
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("orders")
public List<Order> orders() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders;
}
}
간단한 주문정보 리스트를 반환하는 컨트롤러를 만들고 이를 실제로 요청해보면 (나같은 경우) 다음과 같은 에러를 마주하게 된다.
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
이 에러는 왜 발생하냐면 Jackson 라이브러리에서 엔티티를 JSON으로 반환할 때 무한 루프에 빠지기 때문인데 왜 무한 루프에 빠지냐면 Order 엔티티는 Member 엔티티를 참조하고 Member 엔티티가 Order 엔티티를 참조하기 때문에 계속 서로가 서로를 참조하는 무한 루프에 빠지는 것.
Order Entity
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // Order가 persist될 때 order에 넣어져있는 delivery 역시 persist
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
}
Member Entity
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
@Embedded
private Address address;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
이런 경우부터 문제가 생기는데 이를 어찌저찌 해결할 순 있다. Jackson 라이브러리에게 JSON으로 변환할 때 무시하라고 명령하면 된다.
@JsonIgnore 어노테이션을 Order가 참조하는 엔티티 중 Order를 참조하는 엔티티에서 @JsonIgnore 어노테이션을 달아주면 된다.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
@Embedded
private Address address;
@JsonIgnore 🟢🟢🟢🟢
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore 🟢🟢🟢🟢
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
}
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore 🟢🟢🟢🟢
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus deliveryStatus;
}
이렇게 다시 Order를 참조하는 모든 엔티티에 @JsonIgnore를 걸어주고 다시 요청하면 다음과 같은 에러가 출력된다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->com.example.jpabook.domain.Order["member"]->com.example.jpabook.domain.Member$HibernateProxy$7rXeVz14["hibernateLazyInitializer"])
이번엔 다른 에러지만 역시 에러는 발생한다. 이는 또 어떤 경우냐면 org.hibernate.proxy.bytebuddy.ByteBuddyInterceptor에 주목하면 되는데 지연 로딩 전략으로 데이터베이스에서 가져올 때 스프링은 지연 로딩 객체를 객체 그대로로 받아오지 않고 프록시로 받아온다. 그리고 프록시로 받아오는 객체가 바로 ByteBuddyInterceptor이다. 이 때 이 녀석은 초기화되지 않은 상태이기 때문에 리턴값으로 돌려주는 JSON으로 변환하는 과정에서 Jackson 라이브러리는 어찌할 방도가 없는 것이다.
이를 해결하기 위해 Jackson Datatype Hibernate5를 내려받고 @Bean으로 등록하면 지연 로딩 전략으로 가져오는 모든 엔티티에 대해서 그냥 없는값으로 처리를 해준다. 그래서 우선은 저 dependency를 내려받자.
이처럼 이너클래스로 Response에 대한 DTO 클래스를 정의한 후 컨트롤러는 반환 타입을 해당 DTO로 설정해준다. 그리고 해당 DTO 형식에 맞게 데이터를 변환해서 만들어주면 불필요한 데이터 노출도 하지 않고 엔티티가 변경된다고 한들 API 스펙이 변경될 일이 없다.
주의할 점은 "컨트롤러는 반환을 DTO를 만들어서 해야하니 DTO를 만들자!" 이렇게 잘 했는데 DTO 클래스의 필드로 엔티티를 받는 경우가 있다. 이 경우도 안된다. 반환하는 DTO에 엔티티가 있으면 이 엔티티도 DTO로 바꿔줘야 한다.
다음 예시를 보자.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems; //엔티티가 DTO에 포함된 경우
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
order.getOrderItems().stream().forEach(o -> o.getItem().getName());
orderItems = order.getOrderItems();
}
}
OrderDto 클래스를 보면 필드로 List<OrderItem> 타입의 orderItems 필드가 있는데, 여기서 OrderItem은 엔티티다. 이렇게 되면 안된다. 이 또한 DTO로 변경해줘야 한다. 다음 코드처럼.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
DB와 연동된 서비스 로직의 코드를 테스트할 필요가 반드시 생기는데, 이렇게 서비스 로직뿐 아니라 DB와 연동성도 정상적으로 이루어지는지까지 확인하는 테스트를 일반적으로 통합테스트 (Integration Test)라 한다. Spring에서 통합테스트 하는 방법은 굉장히 간단하다.
@SpringBootTest, @Transactional 어노테이션을 사용하면 된다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
public void 회원가입() throws Exception {
//Given
Member member = new Member();
member.setName("hello");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
모든 DB는 사실 어떤 작업을 하고 그 행위에 대한 '커밋'이 이루어져야 DB에 반영되어 Persist 속성을 가지게 되는데, 이처럼 @Test 라는 어노테이션에 @Transactional 어노테이션이 붙어버리면 커밋을 하지 않고 '롤백'을 한다. 하나의 트랜잭션에서 이루어지는 모든 DB와 관련된 작업이 롤백되는 것이다. 물론 이 @Transactional 어노테이션이 @Test가 아닌 다른 서비스에서 붙으면 자동으로 커밋이 되는데 @Test 어노테이션은 그렇지 않다. @Commit 이라는 어노테이션이 없으면 롤백이 기본이다.
따라서, DB와 연동되는 서비스의 테스트를 간단하게 수행할 수 있다. 또한 이 테스트 코드에서 보다시피 필드 주입을 통해 DI를 수행했다.
근데 수행해보면 알겠지만, @SpringBootTest는 SpringBoot가 띄워지기 때문에 속도 차이도 엄청 난다. 그래서 이렇게 특수한 경우에는 어쩔수 없지만 그렇지 않은 경우 순수 자바코드로 테스트 코드를 짜는게 효율적인 것과 동시에 더 좋은 테스트일 확률이 높다.
Spring을 사용하면 중요하게 알아두어야 할 것이 '컴포넌트 스캔'이란 단어다. 이게 무엇인지 공부한 내용을 작성해보고자 한다.
우선 Spring 코드를 보면 이런 어노테이션이 많이 보인다.
@Service
@RequiredArgsConstructor
public class FileStoreImpl implements FileStore {}
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/file")
public class FileController {}
@Service, @RestController, @Controller, @Component, @Repository 등 자주 보이는 어노테이션이 있는데 이게 무얼 의미하는지 알아야한다.
컴포넌트 스캔 및 자동 의존관계 설정
Spring이 아닌 기존의 자바 코드를 생각해보면, 어떤 임의의 클래스에서 다른 클래스의 객체를 불러올 때 이러한 방식으로 코드를 작성한다.
public class FileController {
private final FileService fileService = new FileService();
}
해당 클래스에서 'new'를 사용해서 인스턴스화 하곤했는데, 이러한 객체를 불러오는 것을 스프링은 대신 작업해준다. 더 많은 이점을 가지고. 그것이 컴포넌트 스캔과 자동 의존관계 설정이다.
그럼 어떻게 하는지 알아보자.
@Service
public class FileService {
}
위 코드처럼 @Service라는 어노테이션을 특정 클래스에 추가하면 스프링은 스프링이 띄워질 때 이 클래스를 스프링 컨테이너에 유일하게 하나의 객체로 만들어 보관한다. 그래서 이 클래스가 스프링이 관리하는 하나의 컴포넌트가 되고 그 컴포넌트를 스프링이 띄워질 때 모두 찾아내는 걸 컴포넌트 스캔이라고 하는데 왜 컴포넌트일까? @Service 어노테이션 안으로 들어가보면 이와 같다.
/*
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.stereotype;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* Indicates that an annotated class is a "Service", originally defined by Domain-Driven
* Design (Evans, 2003) as "an operation offered as an interface that stands alone in the
* model, with no encapsulated state."
*
* <p>May also indicate that a class is a "Business Service Facade" (in the Core J2EE
* patterns sense), or something similar. This annotation is a general-purpose stereotype
* and individual teams may narrow their semantics and use as appropriate.
*
* <p>This annotation serves as a specialization of {@link Component @Component},
* allowing for implementation classes to be autodetected through classpath scanning.
*
* @author Juergen Hoeller
* @since 2.5
* @see Component
* @see Repository
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Service {
/**
* The value may indicate a suggestion for a logical component name,
* to be turned into a Spring bean in case of an autodetected component.
* @return the suggested component name, if any (or empty String otherwise)
*/
@AliasFor(annotation = Component.class)
String value() default "";
}
실제로 @Service 어노테이션에 들어와보면 @Component 라는 어노테이션이 있다. 이 때문에 @Service는 @Component 어노테이션을 붙인것과 같이 컴포넌트 스캔이 가능한 것.
그럼 스프링이 띄워질 때 스프링 컨테이너에 자동으로 컴포넌트들을 다 찾아서 등록해주면 이제 우리는 그 객체를 불러다가 사용만 하면 된다. 그 방식이 다음과 같은 방식이다.
@Controller
public class FileController {
private final FileService;
@Autowired
public FileController(FileService fileService) {
this.fileService = fileService;
}
}
이 코드를 보면 FileService를 final로 선언하고 초기화 하지 않은 상태에서 생성자에서 FileService를 초기화 해주는데, @Autowired라는 어노테이션이 보인다. 이 어노테이션은 스프링이 자동으로 스프링 컨테이너에서 보관하고 있는 FileService라는 객체를 이 클래스에 주입해준다는 의미다. 이게 자동 의존관계 설정이다.
그리고 저렇게 생성자가 딱 하나만 존재하는 경우 @Autowired 어노테이션은 생략할 수 있다.
아래 코드가 그 예시이고, 이렇게 작성해도 스프링이 FileService를 자동 주입해준다. @Autowired가 생략된 것.
@Controller
public class FileController {
private final FileService;
public FileController(FileService fileService) {
this.fileService = fileService;
}
}
참고로 @Controller 어노테이션 역시 스프링이 스프링 컨테이너에 객체로 보관해주는 컴포넌트 스캔이 일어난다. 이 @Controller 역시 들어가보면 @Component 라는 어노테이션이 들어있다.
요즘은 @Autowired 보다 더 간단하게 스프링한테 의존성 주입을 맡길 수 있는데 그 코드는 다음과 같다.
@Controller
@RequiredArgsConstructor
public class FileController {
private final FileService;
}
여기서는 생성자도 필요없다. @RequiredArgsConstructor라는 어노테이션을 붙이면 이 어노테이션이 이 클래스가 반드시 가져야하는 인스턴스를 자동으로 주입해준다. 위에보다도 코드가 더 간결해졌다. 이렇게 스프링이 대신 의존관계를 주입해주면 가지는 여러 이점이 있지만 우선 그 중 하나는 유일하게 하나의 객체만을 만들어 사용하는 싱글톤 패턴 방식으로 동작하게 해준다.
그럼 컴포넌트 스캔은 아무곳에 어떤 클래스나 상관없이 @Component라는 어노테이션만 붙으면 가능할까? 그렇지 않다.
어떻게 스프링이 스프링 컨테이너에 객체를 보관하냐면 스프링이 띄워질 때 가장 최초의 시작점인 @SpringBootApplication 어노테이션이 붙은 메인 클래스를 보자.
package com.example.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ServerApplication {
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}
이 클래스를 보면 패키지가 com.example.server로 되어 있다. 스프링은 기본으로 이 패키지 하위에 있는 모든 패키지들만을 찾아 컴포넌트 스캔을 진행한다. 물론 다른 패키지에 있어도 컴포넌트 스캔을 설정하는 방법은 있지만 기본으로 이 메인 클래스의 패키지 하위에 있는 모든 패키지 안에서만 컴포넌트 스캔이 일어난다.
이렇게 사전에 스프링이 제공해주는 어노테이션인 @Service, @Controller와 같은 어노테이션을 사용해서 스프링 컨테이너에 객체를 등록하는 방법이 있고 직접 자바 코드를 이용해서 등록하는 방법도 있다.
자바 코드로 직접 스프링 빈 등록하기
public class FileService {
private final FileRepository fileRepository;
public FileService(FileRepository fileRepository) {
this.fileRepository = fileRepository;
}
}
이러한 FileService 클래스가 있다고 하면, 이 클래스는 생성자로 FileRepository를 받아야한다. 그리고 스프링 컨테이너에 등록하는 어노테이션도 없다. 이제 이 녀석을 자바 코드로 직접 등록해보자.
그 방법은 Configuration 클래스를 하나 새로 만들고 @Configuration 어노테이션을 추가해주자. 다음 코드와 같다.
@Configuration
public class SpringConfig {
@Bean
public FileService fileService() {
return new FileService(fileRepository());
}
@Bean
public FileRepository fileRepository() {
return new FileRepository();
}
}
이렇게 @Configuration 어노테이션을 추가하면 스프링한테 "스프링을 띄우면 이 파일을 확인해서 내가 @Bean 어노테이션을 붙인 녀석들 모두를 스프링 컨테이너에 등록해줘 !" 라고 하는것이다.
그리고 FileService()는 FileRepository를 생성자로 받아야 하니 FileRepository 또한 스프링 빈으로 등록을 하고 등록된 객체를 불러다가 FileService에 넣어준다.
이렇게 자바 코드로 직접 스프링 빈을 등록을 하면 이제 위에서와 같이 의존관계를 위한 의존성 주입이 가능해지는데 의존성 주입(Dependency Injection)을 하는 방법에는 크게 3가지가 있다. 필드 주입, setter 주입, 생성자 주입(위에서 봤던 것).
필드 주입, setter 주입은 이제 거의 사용하지 않는다. 아니 아예 사용하지 않을수도 있다. 그래서 어떻게 하는지만 보고 넘어갈 생각이다.
// 필드 주입
public class FileService {
@Autowired private FileRepository fileRepository;
}
// setter 주입
public class FileService {
private FileRepository fileRepository;
@Autowired
public void setFileRepository(FileRepository fileRepository) {
this.fileRepository = fileRepository;
}
}
필드 주입과 setter 주입은 이렇게 생겨먹었다. 아 물론 필드 주입같은 경우 테스트 코드에서는 종종 쓰이곤한다. 왜냐하면 테스트는 가장 끝단에 있는거니까 그냥 편하게 쓸 수 있는게 가장 좋은거라고 생각하자.
public interface Iterator
Type Parameter:
E - the type of elements returned by this iterator
Iterator는 각 요소를 회수하기 위해 Java의 Collection framework에서 사용된다.
사용 가능한 Method로는 forEachRemaining, hasNext, next, remove가 있다.
Collection
- Declaration
public interface Collection<E> extends Iterable<E>
Type Parameter:
E - the type of elements returned by this iterator
Collection은 하나의 단위로 표현된 각각의 객체의 그룹이다.
사용 가능한 Method로는 add, addAll, clear, contains 등 상당수가 있다.
Iterator Vs. Collection
- Iterator는 next() 또는 remove()를 통해서만 요소 간 변화를 줄 수 있지만, Collection은 add(), iterate, remove(), clear()와 같은 메서드를 사용할 수 있다.
- Iterator는 Collection보다 더 속도가 빠르다. 왜냐하면 Iterator Interface가 더 적은 수의 Operation을 가지고 있기 때문
- Collection을 사용해서 Loop operation을 수행할 때 Collection의 수정, 삭제는 불가능하다. 더 정확한 표현으로는 ConcurrentModificationException이 발생할 수 있다. 즉, 이 데이터를 어디선가 사용하는 중에 다른 어떤곳에서 이 데이터를 삭제 또는 수정하는 경우 데이터 불일치 현상이 발생할 수 있다.
이 ConcurrentModificationException을 해결하고자 Iterator를 사용한다. 예를 들면 loop operation안에서 특정 원소를 삭제하는 경우 다음과 같은 코드를 작성할 수 있다.
Iterator<Link> iterator = autoSyncQueue.iterator();
while (iterator.hasNext()) {
Link li = iterator.next();
// Perform actions on 'li'
if (condition) {
iterator.remove(); // Safely remove the current element
}
}
제목 그대로 Web Server와 WAS(Web Application Server)의 차이가 무엇인지 알아보고 공부한 내용을 작성해보고자 한다.
Web Server
웹 서버는 우선 HTTP 기반으로 동작한다. 그리고 웹 서버는 정적 리소스를 제공한다. 여기서 정적 리소스는 정적인 파일(HTML, CSS, JS, 이미지, 동영상)을 의미한다. 그리고 정적 리소스를 제공한다는 건 그 리소스들을 필요할 때 서버가 Serving을 한다고 생각하면 된다.
가장 대표적인 웹 서버로는 Apache, NGINX가 있다.
그래서 아래 그림을 보면 클라이언트가 특정 요청을 보내면 웹 서버에서는 요청에 응답하기 위해 요청에 걸맞은 정적 리소스를 제공한다.
이를 웹 서버라고 한다.
WAS(Web Application Server)
그렇다면 웹 애플리케이션 서버란 무엇인가? 이 또한 HTTP를 기반으로 동작하는데, 프로그램 코드를 실행해서 애플리케이션 로직을 수행해 준다. 즉, 동적으로 로직을 수행할 수 있단 얘기다. 예를 들면, 로그인할 때 해당 유저가 실제 DB에 있는지 확인하는 조회 과정을 정적 리소스만으로 확인하는 건 불가능한데 이를 실행할 수 있다는 얘기다.
그리고 이 WAS는 웹 서버의 기능을 포함하고 있다. 즉, 정적 리소스도 또한 Serving 해준다.
가장 대표적인 WAS로는 Tomcat, Jetty 같은 녀석들이다.
그러니까 큰 범주로 WAS는 Web Server보다 큰 영역을 가지고 있다고 보면 될 것 같다.
Difference between WAS and Web Server
위에서도 설명한 내용을 토대로 한 문장으로 요약해보면 웹 서버는 정적 리소스를 제공하는 서버이고 웹 애플리케이션 서버는 애플리케이션 로직을 동적으로도 수행이 가능한 서버라고 생각할 수 있다.
그러나, 요즘은 이 둘 간의 경계가 모호하다. 웹 서버도 프로그램을 실행하는(동적으로) 기능을 가지고 있는 경우가 있고 웹 애플리케이션 서버 역시 웹 서버의 기능을 제공하다 보니 경계가 모호해졌다. 그러나 시작점은 저런 차이가 있었다는 것이고 WAS는 애플리케이션 코드를 실행하는데 더 특화되어 있다고 볼 수 있다.
웹 서버와 웹 애플리케이션 서버의 협력
위에 작성한 내용을 토대로 한다면, WAS만으로도 서비스를 제공할 수 있을 것이다. 웹 서버 역할도 WAS는 수행할 수 있기 때문에.
그래서 WAS와 DB만 가지고도 아래 그림처럼 서비스를 제공할 수 있다.
그러나, 위 사진과 같은 시스템 구조는 WAS가 모든것을 담당하고 있기 때문에 비용이 많이 들어간다. WAS는 애플리케이션 로직을 수행하는데 특화된 녀석이고 정적 리소스를 제공할 수는 있지만 이렇게 모든 역할을 다 해버리면 부하가 있을 수 있다.
그리고 애시당초에 애플리케이션 로직과 정적 리소스는 상대적으로 비용 차이가 많이 난다. 애플리케이션 로직 수행의 비용이 훨씬 비싸다.
이 구조의 가장 큰 문제는 애플리케이션 로직을 수행하는 부분에는 아무런 문제가 없는데 정적 리소스의 문제가 생겨 애플리케이션 로직도 수행 불가능한 상태가 되는 경우이다. 그리고 그 반대로도 마찬가지.
그렇기 때문에 정적 리소스는 웹 서버가 처리하게 하고 애플리케이션 로직은 웹 애플리케이션 서버가 처리하도록 역할 분담을 통해 더 좋은 구조를 구성할 수 있다. 아래 그림을 보자.
이런 구조를 가졌을 때 클라이언트가 요청을 하면 정적인 리소스만을 필요한 화면을 요청했을 때 앞단인 웹 서버만으로 처리가 가능해지고 애플리케이션 로직이나 데이터베이스 조회가 필요한 경우 웹 서버는 클라이언트 요청을 WAS에게 위임하여 처리한다. 이런 구조가 더 좋은 구조가 될 수 있다. 이렇게 효율적으로 리소스를 관리할 수 있게 된다면 여기서 파생되는 또 다른 이점이 있는데 그건 이런 경우다.
서비스의 특성에 따라 정적 리소스가 더 많이 사용된다면 정적 리소스를 담당하는 웹 서버를 늘리고 애플리케이션 리소스가 더 많이 사용된다면 애플리케이션 리소스를 담당하는 웹 애플리케이션 서버를 더 늘려 시스템의 안정도를 높일 수 있다. 다음 그림과 같은 모습이다.
마무리
간단하게 WAS와 Web Server의 차이와 협력의 가능성을 알아보았다. 이게 정답이라는 건 아니고 이러한 내용이 있을 수 있다는 점. 항상 계속 배울 게 있다는 게 좋은 일인 거 같다. 추후에 특정 서비스를 만들 때 이 점을 참고해봐야겠다.