728x90

Spring 프레임워크는 자바 애플리케이션 개발에서 가장 널리 사용되는 프레임워크 중 하나입니다. Spring의 핵심 기능 중 하나는 IoC(Inversion of Control, 제어의 역전)과 DI(Dependency Injection, 의존성 주입)입니다.
Spring에서 Bean(빈)은 IoC 컨테이너가 관리하는 객체입니다. IoC는 이러한 Bean을 생성하고 관리하는 역할을 합니다. 즉, IoC 컨테이너는 객체의 생성과 라이프사이클을 개발자가 직접 처리하지 않도록 하고, 필요할 때 Bean을 주입하여 애플리케이션을 구성할 수 있도록 합니다. Spring Boot를 사용하면 이러한 IoC 설정이 자동화되어 개발자가 최소한의 설정만으로도 빠르게 애플리케이션을 개발할 수 있습니다. IoC, Bean, DI에 대해서 알아보겠습니다.


IoC(Inversion of Control, 제어의 역전)란?

IoC 개념 및 필요성

IoC는 객체의 생성 및 관리를 개발자가 직접 하는 것이 아니라 Spring 컨테이너가 담당하도록 하여 객체 간 결합도를 줄이고, 유지보수성을 높이는 패턴입니다. Spring에서는 IoC 컨테이너를 통해 객체를 빈(Bean)으로 관리하고 필요할 때마다 주입하여 사용합니다. 이를 통해 객체간 결합도가 감소고 테스트가 수월해집니다.

Spring이 IoC라고 부르는 이유

Spring에서는 객체의 라이프사이클과 의존성 관리를 개발자가 직접 하지 않고 컨테이너가 관리하는 방식으로 동작합니다. 이는 기존의 방식에서 객체가 직접 자신의 의존성을 찾는 것에서 벗어나 외부 컨테이너가 객체를 생성하고 주입하는 방식으로 전환되었기 때문입니다. 즉, 객체의 제어 흐름이 개발자에서 Spring으로 역전(Inversion) 되었기 때문에 IoC라고 부릅니다.

Bean이란 무엇인가?

Bean의 개념과 역할

Spring에서 Bean이란, Spring IoC 컨테이너가 관리하는 객체를 의미합니다. 개발자가 직접 객체를 생성하는 것이 아니라, Spring이 객체를 생성하고, 필요할 때 주입(Injection)하는 방식을 사용합니다.

Bean의 생명주기

Bean의 생명주기는 크게 다음과 같습니다.

  1. 객체 생성 (@Bean, @Component 등의 설정에 의해 Spring 컨테이너가 객체를 생성)
  2. 의존성 주입 (생성자 또는 세터를 통해 의존성 주입)
  3. 초기화 메서드 실행 (@PostConstruct 활용 가능)
  4. 사용(Ready State)
  5. 소멸(Destroy) (@PreDestroy 활용 가능)

생명주기가 서로 다른 Bean 예시

스코프 설정을 통해 Bean마다 생명주기를 다르게 적용할 수 있습니다. Singleton을 기본값으로 가지며, Prototype, Request 등이 있다.

  • Singleton(기본값) : 애플리케이션 실행 동안 단 하나의 인스턴스만 생성
  • Prototype : 요청할 때마다 새로운 인스턴스를 생성
@Bean
@Scope("prototype")
public Car car() {
    return new Car();
}

Spring IoC 컨테이너

Spring에서는 IoC 컨테이너로 ApplicationContext와 BeanFactory를 제공합니다.

  • ApplicationContextBeanFactory
    • BeanFactory : 가장 기본적인 IoC 컨테이너입니다.
    • ApplicationContext : BeanFactory를 확장한 개념으로, AOP, 이벤트 리스너, 메시지 소스 처리 등 다양한 기능을 제공합니다.
  • Spring Boot에서의 자동 설정
    • Spring Boot에서는 @SpringBootApplication을 사용하면 내부적으로 IoC 컨테이너가 자동으로 설정됩니다.
    • 개발자가 AnnotationConfigApplicationContext 등을 직접 생성할 필요 없이, 빈을 선언하면 자동으로 관리됩니다.

Bean 생성 및 관리 (Java-based 설정)

@Configuration, @Bean, @Component

Java 기반 설정에서 Bean을 등록하려면 @Configuration@Bean을 사용합니다. @Configuration 내부에 @Bean을 정의하면 IoC에서 자동으로 생성 및 관리를 합니다. @Component를 사용하면 @Configuration에 생성하지 않더라도 Spring이 자동으로 빈을 감지하여 등록합니다.

@Configuration
public class AppConfig {
    @Bean
    public Car car() {
        return new Car(engine());
    }

    @Bean
    public Engine engine() {
        return new Engine();
    }
}

@Component
public class CarSystem {}

의존성 주입(Dependency Injection, DI)

DI 원칙을 사용하면 코드가 더 깔끔해지고, 분리가 더 효과적입니다. 결과적으로 클래스를 테스트하기가 더 쉬워지며, 특히 종속성이 인터페이스나 추상 기본 클래스에 있는 경우 단위 테스트에서 스텁이나 모의 구현을 사용할 수 있습니다.
DI는 객체 간 의존성을 Spring이 자동으로 주입해줍니다. 크게 3가지가 있으나, 필드주입은 권장하는 방법이 아니기 때문에 제외하고 생성자 주입과 세터주입을 보겠습니다.

생성자 주입

@Service
public class Car {
    private final Engine engine;

    @Autowired
    public Car(Engine engine) {
        this.engine = engine;
    }
}

가장 권장되는 방법입니다. 생성자를 통하여 필요한, 의존성을 주입받는 방법입니다.

세터 주입

@Service
public class Car {
    private Engine engine;

    @Autowired
    public void setEngine(Engine engine) {
        this.engine = engine;
    }
}

Setter를 통해 의존성을 주입받는 방법입니다. 이 방법은 생성자 주입이 이루어진 후에 이루어집니다.

순환 종속성
예를 들어, 클래스 A는 생성자 주입을 통해 클래스 B의 인스턴스를 필요로 하고, 클래스 B는 생성자 주입을 통해 클래스 A의 인스턴스를 필요로 합니다. 클래스 A와 B의 빈을 서로 주입하도록 구성하면 Spring IoC 컨테이너는 런타임에 이 순환 참조를 감지하고 BeanCurrentlyInCreationException예외를 발생시킵니다. 이를 해결하는 방법 중 하나는 어떤 Bean은 세터 주입으로 구성할 수 있게 수정하는 것입니다. 다른 방법은 @Lazy어노테이션을 사용한 지연 초기화를 사용하는 방법이 있습니다.

DI 활용 예제 - 서비스 계층과 DAO 적용

Spring 애플리케이션에서는 DAO(Data Access Object) 계층과 서비스(Service) 계층을 분리하여 관리합니다. DAO는 데이터베이스와의 직접적인 상호작용을 담당하며, 서비스 계층은 비즈니스 로직을 처리합니다.

@Repository
public class UserRepository {
    public String getUser() {
        return "Spring User";
    }
}

@Service
public class UserService {
    private final UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public String getUserName() {
        return userRepository.getUser();
    }
}

이와 같이 나누면, 비즈니스 로직과 데이터 접근 로직을 분리하여 유지보수를 쉽게 할 수 있습니다.

IoC와 DI의 장점과 단점

  • IoC/DI의 장점
    • 유연한 코드 구조 - IoC/DI를 사용하여 결합도가 감소한다.
    • Mock 객체 활용 가능 - 외부에서 주입이 가능해지면서 Mock 객체를 사용할 수 있게 되고 테스트 용이성이 증가한다.
    • 코드 가독성 증가 - 객체 생성을 중앙 집중화하여 어떤 객체들을 사용하고 있는지 파악하기 쉽다.
  • IoC/DI의 단점
    • 학습 비용이 높음 - 초기 설정이 복잡하여 학습에 대한 부담이 있다.
    • Bean관리의 복잡성 - Bean이 많아지고 관리를 잘못하면 복잡성이 증가한다.

Spring의 IoC와 DI는 객체 관리의 책임을 개발자가 아닌 Spring 프레임워크가 맡도록 함으로써 결합도를 낮추고 유지보수성을 향상시키는 핵심 개념입니다. Spring Boot를 활용하면 IoC 컨테이너 설정이 자동화되어 더욱 빠르게 개발할 수 있습니다. IoC/DI를 효과적으로 활용하면, 더 모듈화된 애플리케이션을 개발할 수 있습니다. 현재는 주니어 개발자로 어떤 객체를 빈으로 설정할지 스코프 설정이나, 생명주기에 대한 고민면서 다양하게 적용해 보고 있다. 요청마다 생성되는 빈을 사용해본다던가, 자주 사용하는 ObjectMapper를 빈으로 등록해서 사용하단던지, 프로젝트의 기본적인 설정을 상수로 사용해 등록한다던지 여러 고민을 하고 있다. 어려운 부분도 많다. 빈을 관리하는 일이 프로젝트가 작으면 어렵지 않지만 커질 수록 더 많은 빈이 생기고 무슨 역할을 하는 객체였는지 알기 어려워지는 등 복잡성이 증가한다. 이런 부분들을 고려하여 공부하고 리팩토링해보고 다양하게 적용하지만 이 모든 것이 결국 비용(유지보수도 결국 비용이다.)으로 연결되므로 경험이 부족하다는게 실감되는 때가 있다.

728x90
728x90

지난번 SpringFramework의 특징을 알아보았다. 그 중 트랜잭션 관리에 대한 내용이 있었고, 이번에 트랜잭션에 대해서 추가로 알아보는 시간을 가져보려고 한다. 트랜잭션이 무엇이고 선언적 트랜잭션의 사용과 격리수준에 대해서 알아보겠다.


데이터베이스 트랜잭션(Database Transaction)은 데이터의 일관성을 유지하고 신뢰성을 확보하는 데 중요한 개념이다. 트랜잭션을 통해 데이터베이스 조작이 안전하게 수행되며, 만약 오류가 발생하면 변경 사항을 롤백하여 원래 상태로 되돌릴 수 있다. 이번 글에서는 트랜잭션의 개념과 ACID 속성, 그리고 Java Spring에서 트랜잭션을 어떻게 활용하는지 살펴보겠다.

트랜잭션(Transaction)은 데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다.

1. 트랜잭션의 4가지 핵심 속성 (ACID)

  1. 원자성(Atomicity)
  • 트랜잭션과 관련된 작업들이 부분적으로 실행되다가 중단되지 않음을 보장한다.
  • 예: 자금 이체 시 보내는 계좌에서 돈이 빠졌지만 받는 계좌에는 입금되지 않는 경우를 방지.
  1. 일관성(Consistency)
  • 트랜잭션 처리 전과 후에 데이터 무결성이 유지되어야 한다.
  • 예: 은행 계좌의 잔고가 음수가 되지 않도록 트랜잭션을 중단시킴.
  1. 독립성(Isolation)
  • 하나의 트랜잭션이 다른 트랜잭션에 영향을 받지 않도록 보호한다.
  • 예: 송금 작업 도중 다른 사용자가 중간 상태의 데이터를 볼 수 없어야 한다.
  1. 지속성(Durability)
  • 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 반영된다.
  • 예: 시스템 장애 후에도 커밋된 데이터는 유지됨.

2. 트랜잭션의 동작 원리

트랜잭션의 상태

  • Active: 트랜잭션이 실행 중인 상태
  • Partially Committed: 일부 작업이 완료되었지만 최종 커밋되지 않은 상태
  • Failed: 오류 발생으로 인해 트랜잭션이 실패한 상태
  • Aborted: 트랜잭션이 취소되고 롤백된 상태
  • Committed: 트랜잭션이 성공적으로 완료된 상태

3. Java와 Spring에서의 트랜잭션 관리

Spring의 선언적 트랜잭션 (@Transactional)

Spring에서는 @Transactional을 사용하여 선언적으로 트랜잭션을 관리할 수 있다.

@Service
public class AccountService {
    @Autowired
    private AccountRepository accountRepository;

    @Transactional
    public void transferMoney(Long senderId, Long receiverId, Double amount) {
        Account sender = accountRepository.findById(senderId).orElseThrow();
        Account receiver = accountRepository.findById(receiverId).orElseThrow();
        sender.withdraw(amount);
        receiver.deposit(amount);
        accountRepository.save(sender);
        accountRepository.save(receiver);
    }
}

4. 트랜잭션 격리 수준 (Transaction Isolation Level)

격리 수준 설명
Read Uncommitted 다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있음
Read Committed 다른 트랜잭션이 커밋한 데이터만 읽을 수 있음
Repeatable Read 동일 트랜잭션에서 같은 데이터를 여러 번 읽어도 동일함
Serializable 가장 엄격한 수준으로 트랜잭션을 직렬화하여 처리

Spring에서 격리 수준을 설정하는 방법:

@Transactional(isolation = Isolation.SERIALIZABLE)

5. 결론

데이터베이스 트랜잭션은 데이터 무결성과 신뢰성을 보장하는 핵심 개념이다. Java Spring에서는 @Transactional을 활용하여 선언적 트랜잭션을 쉽게 적용할 수 있으며, 트랜잭션 격리 수준을 적절히 조절하여 성능과 안전성을 조율할 수 있다. 안정적인 시스템을 구축하려면 트랜잭션 관리 전략을 적절히 활용하는 것이 중요하다.

728x90
728x90

웹 애플리케이션을 개발할 때 가장 중요한 요소 중 하나는 보안(Security)이다. 로그인 및 회원가입 기능을 구현하면서 인증(Authentication)과 권한(Authorization)에 대해 고민하게 되었고, 이를 해결할 수 있는 프레임워크로 스프링 시큐리티(Spring Security)를 선택하게 되었다. 이번 글에서는 스프링 시큐리티의 핵심 기능과 이를 선택한 이유를 정리하고자 한다.

스프링 시큐리티(Spring Security)란?

스프링 시큐리티는 Spring 기반 애플리케이션의 보안 기능을 담당하는 프레임워크로, 인증(Authentication)과 권한 부여(Authorization)를 중심으로 다양한 보안 기능을 제공한다. OAuth2, JWT, 세션 기반 인증 등 다양한 인증 방식을 지원하며, 보안 설정을 손쉽게 적용할 수 있도록 돕는다.

스프링 시큐리티 특징

1. 강력한 인증(Authentication) 및 권한(Authorization) 관리

스프링 시큐리티는 다양한 인증 방식을 지원하며, 역할(Role) 기반의 접근 제어 기능을 제공한다. @PreAuthorize, @Secured 애너테이션을 활용하면 특정 권한을 가진 사용자만 접근할 수 있도록 설정할 수 있다.

예제: 특정 역할(Role)을 가진 사용자만 접근 가능하도록 설정

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")
public String adminPage() {
    return "관리자 페이지";
}

위 코드를 적용하면 ROLE_ADMIN 권한을 가진 사용자만 /admin 페이지에 접근할 수 있다.


2. 필터 기반의 보안 처리 (Spring Security Filter Chain)

스프링 시큐리티는 Servlet Filter 기반으로 동작하며, 요청(Request)이 들어오면 여러 개의 필터를 통과하면서 보안 로직을 수행한다. 이를 통해 인증, 권한 검사, 세션 관리, CSRF 보호 등을 처리할 수 있다.

예제: 특정 요청에 대한 보안 설정 적용

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/admin/**").hasRole("ADMIN") // 관리자만 접근 가능
            .anyRequest().authenticated() // 나머지 요청은 인증 필요
        )
        .formLogin(); // 기본 로그인 페이지 사용
    return http.build();
}

위 설정을 적용하면 /admin/** 경로는 ROLE_ADMIN 권한을 가진 사용자만 접근할 수 있으며, 그 외의 요청은 로그인한 사용자만 허용된다.


3. CSRF(Cross-Site Request Forgery) 공격 방어

스프링 시큐리티는 기본적으로 CSRF 보호 기능이 활성화되어 있으며, POST 요청 시 CSRF 토큰을 요구한다. 이를 통해 사용자의 세션을 가로채어 악의적인 요청을 수행하는 공격을 방어할 수 있다.

예제: Thymeleaf에서 CSRF 토큰을 포함한 폼 제출

<form action="/submit" method="post">
    <input type="hidden" name="_csrf" value="${_csrf.token}" />
    <button type="submit">전송</button>
</form>

CSRF 토큰을 포함하지 않으면 보안 정책에 의해 요청이 차단된다.


4. 세션 관리 및 OAuth2 / JWT 지원

스프링 시큐리티는 세션 기반 인증과 JWT(JSON Web Token) 기반 인증을 모두 지원하며, OAuth2 로그인을 간단하게 설정할 수 있다.

예제: OAuth2 로그인 활성화

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .oauth2Login(oauth2Login ->
                        oauth2Login.successHandler(customAuthenticationSuccessHandler)); // OAuth2 로그인 활성화
    return http.build();
}

이 설정을 적용하면 구글, 깃허브 등의 OAuth2 로그인을 쉽게 연동할 수 있다.


5. 패스워드 암호화 및 안전한 인증 저장소 관리

스프링 시큐리티는 BCryptPasswordEncoder를 제공하여 비밀번호를 안전하게 암호화할 수 있다. 또한, UserDetailsService를 구현하여 데이터베이스 기반의 사용자 인증을 간단하게 처리할 수 있다.

예제: 비밀번호 암호화 및 사용자 인증 저장

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
@Autowired
private PasswordEncoder passwordEncoder;

public void registerUser(String username, String rawPassword) {
    String encodedPassword = passwordEncoder.encode(rawPassword);
    userRepository.save(new User(username, encodedPassword));
}

위 코드를 적용하면 사용자의 비밀번호를 안전하게 저장하고, 인증 시 비교하여 로그인 검증을 수행할 수 있다.


결론

스프링 시큐리티를 배우면서 처음에는 개념이 복잡하고 설정이 많아 어려움을 느꼈다. 하지만 직접 로그인 기능을 구현하고 OAuth2, JWT 인증을 연동해 보면서 보안의 중요성을 체감할 수 있었다.
스프링 시큐리티는 강력한 인증 및 권한 관리 기능을 제공하며, 보안이 중요한 애플리케이션 개발에 필수적인 프레임워크이다. CSRF 방어, OAuth2 연동, 필터 기반 보안 관리 등 다양한 기능을 지원하며, 확장성과 유지보수성이 뛰어나다. 앞으로도 지속적으로 학습하여 더욱 안전한 웹 애플리케이션을 개발하는 것이 목표이다.

728x90
728x90

스프링 프레임워크(Spring)

개발자로서 처음 웹 애플리케이션을 만들 때 가장 먼저 부딪히는 고민 중 하나는 어떤 프레임워크를 사용할 것인가이다. Python의 Django, JavaScript의 Express.js, Ruby on Rails 등 다양한 선택지가 있었지만, 나는 Java 기반의 스프링 프레임워크(Spring Framework)를 선택했다. 왜 많은 프레임워크 중에서 스프링을 선택했는지, 그리고 배우면서 느낀 점을 공유하고자 한다.

스프링은 기업 환경에서 가장 널리 사용되는 Java 기반 프레임워크 중 하나다. 스프링 공화국이라고 칭할 정도로 많은 기업에서 사용하고 있다. 처음에는 XML 설정과 복잡한 개념들로 인해 어렵게 느껴졌지만, 점차 개념을 이해하면서 강력한 기능과 확장성을 제공한다는 점을 깨닫게 되었다. 특히, 객체 지향 프로그래밍의 원칙을 잘 따르고 있으며, 대규모 애플리케이션 개발에 적합하다는 점에서 큰 매력을 느꼈다.

스프링을 사용하는 이유

1. 객체 지향 프로그래밍과 스프링의 DI

의존성 주입(Dependency Injection, DI). 객체 간의 의존성을 프레임워크가 관리해 주기 때문에 결합도를 낮추고 유지보수를 용이하게 만들 수 있다. 또한, Java Config, 애너테이션(@Autowired, @Component) 등을 활용하여 유연한 의존성 주입이 가능하다.

2. AOP를 활용한 효율적인 코드 관리

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍) 지원. 이를 활용하면 로그 기록, 트랜잭션 관리, 보안 처리 등의 부가적인 기능을 핵심 로직과 분리할 수 있다. @Transactional, @Aspect 등의 애너테이션을 사용하면 코드 중복 없이 이러한 기능을 쉽게 적용할 수 있다.

3. 강력한 트랜잭션 관리와 데이터 일관성 유지

선언적 트랜잭션 관리. 대부분의 웹 애플리케이션은 데이터베이스와 연결되며, 데이터 일관성을 유지하는 것이 매우 중요하다. 스프링은 @Transactional을 통해 선언적 트랜잭션 관리를 제공하며, JDBC, JPA, Hibernate 등 다양한 데이터 접근 기술과 쉽게 연동할 수 있다.

4. 유연한 웹 개발: Spring MVC

Spring MVC 강력한 웹 프레임워크. @Controller, @RestController, @RequestMapping 등의 애너테이션을 활용하면 RESTful API 및 전통적인 웹 애플리케이션을 쉽게 개발할 수 있다. 특히, Spring Boot를 활용하면 복잡한 설정 없이 빠르게 웹 애플리케이션을 구축할 수 있다.

5. 다양한 데이터 접근 기술과 ORM 지원

다양한 데이터 접근 기술 지원. JDBC, JPA, MyBatis, Hibernate 등을 사용할 수 있으며, JdbcTemplate, JpaRepository 등의 기능을 제공하여 데이터베이스 작업을 단순화할 수 있다. 이를 통해 개발자는 비즈니스 로직에 집중할 수 있는 환경을 제공받는다.

6. 테스트 친화적인 환경 제공

테스트 용이성. DI를 활용하면 Mock 객체를 쉽게 주입할 수 있으며, Spring Test, MockMvc 등을 사용하면 웹 애플리케이션의 기능을 쉽게 테스트할 수 있다. 이는 유지보수성과 품질 관리에 큰 도움이 된다.

다른 프레임워크와 비교해 본 스프링의 강점

다른 언어의 프레임워크와 비교해 보면 스프링은 다음과 같은 차별점을 가진다.

특징 Spring (Java) Django (Python) Express (Node.js)
DI & IoC 지원 ✅ 강력한 지원 ❌ 없음 ❌ 없음
AOP 지원 ✅ 지원 ❌ 없음 ❌ 없음
트랜잭션 관리 ✅ 선언적 관리 (@Transactional) ⚠️ 제한적 지원 ❌ 없음
ORM 지원 ✅ JPA, Hibernate ✅ Django ORM ✅ Sequelize, TypeORM
웹 프레임워크 ✅ Spring MVC ✅ Django (MTV) ✅ Express
예외 처리 @ExceptionHandler ❌ 수동 처리 필요 ❌ 수동 처리 필요
테스트 지원 ✅ MockMvc, Spring Test ✅ Django Test Framework ✅ Jest, Mocha
확장성 ✅ Spring Boot, Cloud ✅ Django Rest Framework ✅ 다양한 미들웨어 지원
설정 방식 ✅ XML, Java Config, YAML ✅ settings.py ✅ JavaScript/JSON 기반

스프링을 배우면서 처음에는 복잡한 설정과 개념 때문에 어려움을 겪었다. 하지만 점차 개념을 이해하고 나니, 객체 지향 프로그래밍을 더 깊이 이해할 수 있는 계기가 되었다. 또한, 트랜잭션 관리, AOP, DI 등의 개념을 익히면서 효율적인 애플리케이션 설계가 가능해졌다.
주니어 개발자로서 스프링을 선택한 이유는 대규모 애플리케이션 개발에 적합한 강력한 기능과 확장성 때문이다. DI, AOP, 트랜잭션 관리, 테스트 용이성 등 다양한 장점을 제공하며, 이를 통해 유지보수성과 생산성을 높일 수 있다. 앞으로도 스프링을 깊이 탐구하며 개발자로서 성장해 나가고자 한다.
각 특징을 Azure에서 작성하는 방식대로 키워드 & 설명 순서로 작성해보았다. 읽는데 도움이 되었는지 모르겠다.

728x90
728x90

개발을 공부하면서 여러 언어를 접해봤지만, 결국 자바(Java)를 선택했다. 배우다 보니 확실히 장점이 많고, 실제로도 많이 쓰이는 언어라는 걸 알게 되었다. 내가 공부하면서 알게 된 자바의 특징을 정리해보려고 한다.


자바(Java)의 주요 특징

1. 플랫폼 독립성 – 한 번 작성하면 어디서든 실행 가능

자바는 JVM(Java Virtual Machine) 위에서 실행되기 때문에, 운영체제에 상관없이 같은 코드가 실행된다. 쉽게 말해, 윈도우에서 만든 프로그램을 리눅스나 맥에서도 실행할 수 있다는 뜻이다. 코드를 .class 파일로 변환한 후, JVM이 알아서 해석해주기 때문에 이런 게 가능하다. 이게 바로 자바의 "Write Once, Run Anywhere" 원칙이다.

2. 객체 지향 프로그래밍(OOP)

자바는 객체 지향 언어라서 캡슐화, 상속, 다형성, 추상화 같은 개념을 활용할 수 있다. 처음에는 어렵게 느껴졌지만, 규모가 커지는 프로젝트에서는 유지보수하기 좋고, 재사용성이 높다는 걸 알게 됐다. 특히 인터페이스(interface)나 추상 클래스(abstract class)를 활용하면 유연하게 설계할 수 있어서 좋다.

3. 자동 메모리 관리 – 가비지 컬렉션(GC)

C나 C++은 개발자가 직접 메모리를 해제해야 하지만, 자바는 JVM의 가비지 컬렉터(GC)가 필요 없는 객체를 자동으로 정리해준다. 덕분에 메모리 누수를 걱정할 필요가 없고, 메모리 관리에 신경을 덜 써도 된다. 물론, GC가 언제 동작하는지 잘 이해하면 더 최적화할 수 있다.

4. 멀티스레드 – 동시에 여러 작업 처리 가능

자바는 멀티스레드를 지원하기 때문에, 여러 작업을 동시에 처리할 수 있다. 예를 들어, 웹 서버가 여러 요청을 동시에 처리해야 할 때, 멀티스레드가 필수다. Thread 클래스나 Runnable 인터페이스를 활용해서 구현할 수도 있고, CompletableFuture 같은 걸 활용하면 더 편리하게 비동기 처리를 할 수 있다.

5. 표준 라이브러리 – 기본 제공 기능이 많음

자바는 기본적으로 엄청나게 많은 라이브러리를 제공한다. 파일 입출력(IO), 데이터 구조(컬렉션 프레임워크), 네트워크, 날짜 및 시간 처리 등 다양한 기능이 있어서 굳이 새로 구현할 필요 없이 가져다 쓰면 된다. 덕분에 생산성이 높아진다.

6. 생태계와 프레임워크

자바는 단순한 언어가 아니라, Spring, Hibernate, MyBatis, JPA 같은 프레임워크가 엄청 많다. 특히 Spring은 백엔드 개발에서 거의 필수적으로 쓰이기 때문에, 자바를 배운다면 자연스럽게 Spring도 같이 배우게 된다. 또한, Gradle, Maven 같은 빌드 도구도 있어서 프로젝트 관리가 편하다.

7. 보안성 – 안전한 실행 환경 제공

자바는 바이트코드 검증, JVM 보안 매니저 같은 기능을 통해 보안을 강화하고 있다. 그리고 기본적으로 암호화, 인증, 접근 제어 같은 보안 관련 기능도 제공하기 때문에, 보안이 중요한 기업에서도 많이 사용된다.

8. 커뮤니티와 지속적인 발전

자바는 Oracle, OpenJDK, GraalVM 같은 다양한 그룹에서 지속적으로 발전하고 있다. 또, LTS(Long-Term Support) 버전이 있어서 기업에서도 안정적으로 사용할 수 있다. 커뮤니티도 크고 자료도 많아서, 막힐 때 검색하면 해결책을 금방 찾을 수 있다.


자바를 선택한 이유

개발을 하다 보면 어떤 언어를 선택할지 고민이 많아진다. 내가 자바를 선택한 이유를 정리하면 다음과 같다.

  • 운영체제에 상관없이 실행 가능해서 유지보수가 편하다.
  • 객체 지향 언어(OOP)라서 유연성과 확장성이 높다.
  • 가비지 컬렉션(GC) 메모리 관리가 자동으로 이루어진다.
  • 멀티스레드 지원 덕분에 서버 개발에 유리하다.
  • Spring 같은 프레임워크가 있어서 백엔드 개발이 쉽다.
  • 보안과 커뮤니티 지원이 탄탄해서 기업에서도 많이 사용된다.
728x90
728x90

Spring Boot에서 WebSocket 테스트하기

Spring Boot 애플리케이션에서 WebSocket 기능을 구현한 후, 테스트를 진행했습니다. @SpringBootTestWebSocketStompClient를 활용하여 WebSocket 통신을 테스트하고, AssertJ를 사용하여 검증했습니다.

Spring Boot 테스트 설정 및 포트 구성

WebSocket 테스트를 위해 @SpringBootTest 어노테이션을 사용하여 통합 테스트 환경을 구성했습니다. 이때, webEnvironment 속성을 SpringBootTest.WebEnvironment.RANDOM_PORT로 설정했습니다. DEFINED_PORT를 사용하여 지정된 포트를 사용할 수도 있습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketTest {
    @LocalServerPort
    private int port;

    // 테스트에 필요한 필드 및 메서드 정의
}

@LocalServerPort 어노테이션을 통해 할당된 포트를 주입받고 클라이언트 설정 시 사용했습니다

WebSocket 클라이언트 설정

테스트에서 WebSocket 클라이언트 역할을 수행하기 위해 WebSocketStompClient를 설정합니다. 이 클라이언트는 STOMP 프로토콜을 사용하여 서버와 통신합니다.

private WebSocketStompClient stompClient;

@BeforeEach
void setUp() {
    // SockJS 클라이언트 생성
    List<Transport> transports = List.of(new WebSocketTransport(new StandardWebSocketClient()));
    SockJsClient sockJsClient = new SockJsClient(transports);

    // WebSocketStompClient 생성 및 설정
    stompClient = new WebSocketStompClient(sockJsClient);
    stompClient.setMessageConverter(new MappingJackson2MessageConverter());
}

SockJsClientWebSocketTransport를 사용하여 다양한 전송 방식을 지원하며, MappingJackson2MessageConverter를 통해 JSON 형식의 메시지를 처리했습니다.

BlockingQueue와 CompletableFuture 비교

테스트에서 비동기적으로 수신되는 메시지를 처리하기 위해 BlockingQueue를 사용습니다. 이는 메시지를 대기열에 넣고, 테스트 코드에서 이를 가져와 검증하는 방식입니다.

private BlockingQueue<MessageDto> blockingQueue;

@BeforeEach
void setUp() {
    blockingQueue = new LinkedBlockingQueue<>();
    // stompClient 설정 코드
}

또한, BlockingQueue외에 CompletableFuture를 사용하여 비동기 작업의 완료를 기다리고 결과를 처리할 수 있습니다.

CompletableFuture<MessageDto> completableFuture = new CompletableFuture<>();

// 메시지 수신 시
completableFuture.complete(receivedMessage);

// 테스트 코드에서
MessageDto message = completableFuture.get(3, TimeUnit.SECONDS);

BlockingQueue는 여러 스레드에서 안전하게 접근할 수 있는 반면, CompletableFuture는 단일 결과를 비동기적으로 처리하는 데 유용합니다. 테스트의 복잡성과 요구사항에 따라 적절한 방식을 선택하면 됩니다.

WebSocket 클라이언트 구독 시 FrameHandler 설정

서버로부터 수신한 메시지를 처리하기 위해 StompFrameHandler를 구현한 핸들러를 설정합니다.

class DefaultStompFrameHandler implements StompFrameHandler {
    @Override
    public Type getPayloadType(StompHeaders headers) {
        return MessageDto.class;
    }

    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        blockingQueue.offer((MessageDto) payload);
    }
}

getPayloadType 메서드에서는 수신할 메시지의 타입을 지정했고, handleFrame 메서드에서는 수신한 메시지를 blockingQueue에 추가합니다.

Byte[]와 DTO 클래스 비교

초기에 테스트를 구현할 때, 컨버터를 적용하지 않아 Byte[]로 데이터를 처리했습니다. 현재는 컨버터로 변경하였지만 사용했던 경험이 있어 비교하여 적어봅니다.
StompFrameHandlergetPayloadType 메서드에서 반환하는 타입을 byte[].class로 설정하면, 수신한 메시지를 바이트 배열로 처리하게 됩니다. 이 경우, 수동으로 바이트 배열을 원하는 객체로 변환해야 합니다.

@Override
public Type getPayloadType(StompHeaders headers) {
    return byte[].class;
}

@Override
public void handleFrame(StompHeaders headers, Object payload) {
    byte[] bytes = (byte[]) payload;
    // 바이트 배열을 문자열로 변환
    String message = new String(bytes, StandardCharsets.UTF_8);
    // JSON 문자열을 객체로 변환
    MessageDto messageDto = new ObjectMapper().readValue(message, MessageDto.class);
    blockingQueue.offer(messageDto);
}

반면, MessageDto.class로 설정하면 메시지 컨버터가 자동으로 JSON을 MessageDto 객체로 변환해주므로 코드가 더 간결해집니다. DTO 클래스를 직접 사용하는 것이 편리했습니다. 바이트 배열을 사용하는 것은 특별한 이유가 있을 때 사용하면 될 것 같습니다.

전체 코드 및 AssertJ를 사용한 검증

아래는 WebSocket 기능을 테스트하기 위한 전체 코드입니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketTest {

    @LocalServerPort
    private int port;

    private WebSocketStompClient stompClient;
    private BlockingQueue<MessageDto> blockingQueue;

    private String WEBSOCKET_URI;
    private final String WEBSOCKET_TOPIC = "/topic";

    @BeforeEach
    void setUp() {
        // 메시지 컨버터 설정
        MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
        ObjectMapper objectMapper = converter.getObjectMapper().registerModule(new JavaTimeModule());

        // BlockingQueue 초기화
        blockingQueue = new LinkedBlockingQueue<>();

        // WebSocketStompClient 설정
        stompClient = new WebSocketStompClient(new SockJsClient(
                List.of(new WebSocketTransport(new StandardWebSocketClient()))));
        stompClient.setMessageConverter(converter);

        // WebSocket URI 설정
        WEBSOCKET_URI = "ws://localhost:" + port + "/ws";
    }

    @Test
    @DisplayName("WebSocket 메시지 전송 및 수신 테스트")
    void testWebSocketMessaging() throws Exception {
        Long roomId = 1L;
        String username = "user1";

        // WebSocket 연결 시 쿠키 설정
        HttpHeaders headers = new HttpHeaders();
        headers.add("Cookie", "login=" + username);

        // WebSocket 세션 연결
        StompSession session = stompClient
                .connectAsync(WEBSOCKET_URI + "?roomId=" + roomId, new WebSocketHttpHeaders(headers),
                        new StompSessionHandlerAdapter() {})
                        // 연결시도를 위한 최대 1초 대기
                .get(1, TimeUnit.SECONDS);

        // 특정 주제 구독
        session.subscribe(WEBSOCKET_TOPIC + "/room" + roomId, new DefaultStompFrameHandler());

        // 전송할 메시지 생성
        MessageDto messageToSend = new MessageDto("TEXT", "hi", "user1", "");

        // 메시지 전송
        session.send("/app/room" + roomId, messageToSend);

        // 메시지 수신 및 검증
        MessageDto receivedMessage = blockingQueue.poll(3, TimeUnit.SECONDS);

        assertThat(receivedMessage).isNotNull();
        assertThat(receivedMessage.getType()).as("메시지 타입 확인").isEqualTo(MessageType.TEXT);
        assertThat(receivedMessage.getContent()).as("메시지 내용 확인").isEqualTo(messageToSend.getContent());
        assertThat(receivedMessage.getSender()).as("메시지 발신자 확인").isEqualTo(messageToSend.getSender());
    }

    // StompFrameHandler 구현
    class DefaultStompFrameHandler implements StompFrameHandler {
        @Override
        public Type getPayloadType(StompHeaders headers) {
            return MessageDto.class;
        }

        @Override
        public void handleFrame(StompHeaders headers, Object payload) {
            blockingQueue.offer((MessageDto) payload);
        }
    }
}

결론

위의 테스트 코드를 통해 Spring Boot 애플리케이션에서 WebSocket 기능을 효과적으로 검증할 수 있었습니다. @SpringBootTestWebSocketStompClient, @ActiveProfile를 활용하여 통합 테스트를 구성하고, AssertJ를 사용하여 수신된 메시지의 내용을 검증함으로써 WebSocket 통신의 신뢰성을 확인할 수 있었습니다.

728x90
728x90

웹소켓

WebSocket은 ws 프로토콜을 기반으로 클라이언트와 서버 사이에 지속적인 완전 양방향 연결 스트림을 만들어 주는 기술입니다. 출처 : https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications

연결은 클라이언트에서 연결요청을 보내면

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

서버에서 응답을 통해 웹소켓 프로토콜로 업그레이드한다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

그러면 실시간 양방향 통신이 되는 웹소켓을 사용할 수 있게 된다.

웹소켓은 데이터가 크면 조각으로 나누어 보낼 수 있고 그것에 대한 설명은 웹소켓 프로토콜에 대한 웹소켓에서 확인할 수 있다. 또한, 웹소켓은 일반적으로 보낼 수 있는 데이터에 제한이 있을 수 있다. 해당 내용은 스프링 Doc도 확인이 가능하며, 실제로도 웹소켓 이용중 연결이 끊기는 경우를 경험할 수 있을 것이다. 데이터가 너무 크면 서버의 부하가 있을 수 있어 제한이 있다. 하지만 이러한 제한도 설정을 통해 해결할 수 있는 방법이 있다. 아니면 큰데이터를 조각화하여 보내고 서버에서 합쳐서 대응하는 방법 등이 있다. 또한, 한번 연결된 웹소켓이 연결이 끊어지는 경우도 있다. 재연결에 대한 방안도 있으니 문제가 된다면 찾아보자.
웹소켓 재연결 핸들링한 글도 있으니 참고해보면 좋을 것 같다. 이 글은 다른 환경 및 라이브러리를 사용했으므로 어떻게 대처하는지에 대한 참고만 해보자.

웹소켓에 대한 대략적인 내용을 봤다. 이후에는 웹소켓을 java에서 사용하는 간략한 예제와 함께 봐보자.

Java에서의 웹소켓 사용하기

@Configuration
@EnableWebSocketMessageBroker
public class CustomWebsocketConfig implements WebSocketMessageBrokerConfigurer{}

자바에서 웹소켓을 사용하기 위해서 위의 클래스와 어노테이션만 있어도 Stomp를 사용하는 Websocket을 사용할 수 있다. Spring이 인메모리를 사용해 메세지브로커 역할을 하는 것이다. 만약, 더 많은 기능이 필요하다면 외부 브로커(RabbitMQ 등)를 사용해서 설정을 하면된다.
자바에서는 Stomp를 사용하기를 여러 이유에서 권하고 있다. Stomp사용 이점

나는 websocket을 사용하기 위한 설정 뿐만 아니라,
HandshakeHandler와 HandshakeIntercepter, EventListener를 같이 사용했다.
웹소켓에 Principle 인터페이스를 주입할 수 있는데 스프링 시큐리티를 같이 사용하면 스프링 시큐리티에서 Security Context에서 Authentication객체를 가져와서 자동으로 주입해준다고 한다. 하지만 내가 만든 프로젝트에서는 security를 적용하지 않아서 직접 주입을 해야했고, 따라서 HandshakeHandler와 HandshakeIntercepter를 사용하게 되었다. EventListener는 웹소켓 연결이 끊겼을 때 동작을 구현해 두었다.


예시 코드

@Configuration  
@EnableWebSocketMessageBroker  
@RequiredArgsConstructor  
public class SimpleWebsocketConfig implements WebSocketMessageBrokerConfigurer {  
    private final CustomHandshakeHandler customHandshakeHandler;  
    private final WebsocketHandshakeInterceptor msgHandshakeInterceptor;

    @Override  
    public void registerStompEndpoints(StompEndpointRegistry registry) {  
        registry.addEndpoint("/ws")  
                .addInterceptors(msgHandshakeInterceptor)  
                .setHandshakeHandler(customHandshakeHandler)  
                .withSockJS();
    }  

    @Override  
    public void configureMessageBroker(MessageBrokerRegistry registry) {  
        registry.enableSimpleBroker("/topic");  
        registry.setApplicationDestinationPrefixes("/app");  
    }
@Component  
@RequiredArgsConstructor  
public class WebsocketHandshakeInterceptor implements HandshakeInterceptor {  
    @Override  
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        // 유저정보를 처리하여 attributes에 주입
        // 웹소켓 연결시 쿼리스트링을 사용할 수 있어서 원하는 정보를 받을 수도 있다.
        return true;  // intercept를 사용하려면 true를 반환해야한다.
    }  

    @Override  
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {  

    }  
}
@Component  
public class CustomHandshakeHandler extends DefaultHandshakeHandler {  
    @Override  
    public Principal determineUser(  
            ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {  
        // attributes.get(유저정보);  
        // return Principle 구현체;
    }  
}

웹소켓을 사용하기 위해 위 3개의 클래스를 사용했다. 위에서 언급했듯 설정을 위한 cofigurer를 사용하고 유저정보 주입을 위한 handler와 intercepter를 사용했다.

유저정보가 주입되는 과정은

  1. 클라이언트에서 핸드쉐이크 요청
  2. 인터셉터에서 유저정보를 attribute에 추가
  3. 핸들러에서 attribute의 유저정보를 가져와서 websocket에 넣어준다.

유저를 넣는 과정이 이럴뿐이지 다른 정보도 넣을 수 있다.

코드 설명

가장 중요한 설정을 살펴보자.
엔드포인트 설정과 메세지브로커 설정이 있다.
먼저, 엔드포인트 설정

  1. 엔드포인트 : 웹소켓 클라이언트를 연결하기 위한 엔드포인트이다. 클라이언트에서 웹소켓에 연결하기 위해서 해당 엔드포인트가 필요하다. 엔드포인트는 1개만 되는 것이 아니라 다양한 엔드포인트를 설정할 수 있다.
  2. 핸들러, 인터셉터(선택): 각 엔드포인트에 추가할 인터셉터와 핸들러를 추가해주면 된다.
  3. sockJs : sockJs를 사용할 것이라면 withSockJS()를 붙여줘야 한다.

SockJs는 웹소켓뿐만 아니라, polling이나 HTTP Streaming을 선택적으로 연결해준다. 물론 주로 웹소켓을 연결해준다. Spring Doc에도 내용이 있다.

다음은 브로커 설정이다.

  1. 심플브로커("/topic") : 브로커를 찾기위한 접두어이다. 클라이언트에서 구독을 할 때 사용한다. 그래야 브로커가 받은 메세지를 전달해줄테니까. 1개말고 여러개를 추가할 수 있다. 스프링 예제에서는 "/queue"도 추가되어 있다.
  2. 어플리케이션프리픽스("/app") : 클라이언트에서 서버로 데이터를 보낼 때 사용한다. "/app" 이후의 Path를 찾아서 데이터를 처리한다. 각 목적지에 맞게 컨트롤러에서 찾아준다.

그러면 메세지를 받아야하는 서버 설정만 하면되는건가? 아니다. 아까 "/app"을 설정해주었다. 이 접두어에 보내진 메세지를 처리하는 로직이 필요하다.
컨트롤러에서 @MessageMapping을 사용하여 지정된 패스에 따라 메세지를 전달한다. Payload만 받아서 처리할 수도 있고, Path에서 변수를 받을 수도, principle을 사용할 수도 있다. 이외에도 더 있으니 필요한 파라미터를 받아서 사용하자.

@MessageMapping("패스/{id}")  
public void roomSendMsg(@DestinationVariable(value = "id")Long id,  
                        DTO클래스 Payload,  
                        Principal user){  
    SimpMessagingTemplate template = new SimpMessagingTemplate();
    String dest = "/topic" + destination;  
    template.convertAndSend(dest, message);
}

메세지를 보내는 방법은 SimpMessagingTemplate을 사용했다. 목적지로 생각하는 브로커 접두어에 목적지를 추가하고 데이터를 보내면 된다. 외부 브로커는 다를 수 있다. 참고하자.


클라이언트(브라우저)에서 연결

클라이언트에서는 어떻게 연결할까?
나는 Thymeleaf를 사용하여 html에 javascript로 직접 script를 작성했다. 따라서 javascript에서 사용할 수 있는 StompJS를 사용했다.

const sockjs = new SockJS(엔드포인트, {}, {});  

// stompJs 버전 변경 후  
const stompClient = new StompJs.Client({  
    webSocketFactory: () => sockjs
});  

stompClient.onConnect = function (frame) {
    stompClient.subscribe(`/topic`, function (data) {  
        // data를 처리하는 로직
    }  
}
stompClient.activate();
stompClient.publish({  
    destination: `/app/패스`,  
    body: JSON.stringify({ msg: msg })  
})

구독과 메시지를 보내는 방법이다. 연결을 할 때 구독을 해주었다. 구독은 원하는

  1. 브로커 주소를 넣고
  2. 구독을 통해 데이터를 처리할 함수를 추가하면 된다.

그리고 activate는 해주도록 하자. 그래야 동작하니까.
예시에는 브로커에 "/topic"만 넣었으나, 서버측 컨트롤러에서 보면 destination이 추가되는 것을 볼 수 있다. 원하는 브로커를 잘 선택해야한다. 예를 들어 서버에서 메세지를 /topic/site로 보냈는데 구독을 /topic만 하면 데이터를 전달받을 수 없다. 구독 할 때 /topic/site를 추가해야한다.

반대로 메세지를 보낼때는 @MessageMapping 어노테이션에 맞게 입력해주어야 메세지가 제대로 전달된다. Payload(body)로 Json형태로 보냈으나, binary데이터도 보낼 수 있다. Stomp는 텍스트와 binary데이터를 지원한다.


이렇게 Websocket과 Stomp를 사용해서 아주 간단한 실시간채팅을 구현할 수 있다. 단순히 연결만 하는 것은 그다지 어려운 내용은 아니였다. 하지만 Handler와 intercepter가 어떻게 동작하는지, 웹소켓이 연결되는 과정, 데이터 크기 제한 등 다양한 문제를 다루었었고 완전히 해결한 문제, 부분적으로 해결한 문제 등 다사다난했다. 하지만 Stomp를 사용한 웹소켓은 매우 익숙하게 사용할 수 있게 되었다. 가장 힘들었던 것은 데이터 크기제한과 extension(Implementation-Specific Limits)과 관련한 것이었다. 부분적으로 밖에 해결하지 못한 것중 하나이다. 서버제한이나, 조각화 등 다양한 방법으로 접근해봐야 하는 부분이여서 당장 급하지않아서 차차 해결하면 될 것이다.

Java에서 웹소켓을 테스트하는 것도 다루어 보겠다.

728x90
728x90

Spring Boot WebMvcTest에서 발생한 Jackson Mapping 문제 해결 과정

Spring Boot에서 @WebMvcTest를 사용하여 컨트롤러 테스트를 진행하던 중, 아래와 같은 에러가 발생했다. 

[Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: (was java.lang.UnsupportedOperationException)]

UnsupportedOperationException의 설명을 보면 "Thrown to indicate that the requested operation is not supported.
This class is a member of the Java Collections Framework."라고 설명하고 있다. 처음에는 테스트 코스에 사용하고 있는 List에서 문제가 있는 건 아닌지 확인했으나, List부분은 모두 잘 동작했다. 그럼 JSON을 쓸 수 없다고 했으니, LocalDateTime에서 문제를 만드는 건가 확인했는데 그것도 아니었다. 그러면 어디서 발생하는 문제일까? 찾아보기 위해 디버거를 사용하여 계속 추적했다.

com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.UnsupportedOperationException) (through reference chain: org.springframework.data.domain.Unpaged["offset"])

MockMvc에서 MvcResult를 반환하는 과정에서 이러한 에러가 발생하여 찾기가 어려웠다. 에러로그를 계속 추적했고 결국 결정적인 로그를 찾을 수 있었다. 에러 로그를 분석한 결과, 보다 구체적인 원인을 다음과 같이 확인할 수 있었다. 

문제 원인

Spring Data의 Page 인터페이스를 JSON으로 변환하는 과정에서 Unpaged 인스턴스를 직렬화하려 할 때 UnsupportedOperationException이 발생한 것이다. 아래와 같이 코드를 작성했었으며, Page Interface와 Jackson이 상호작용하면서 발생한 문제였다. Pageable을 사용한 페이징 응답을 직렬화하는 과정에서, Unpaged 객체가 Jackson에 의해 처리되지 못하면서 예외가 발생했다. PageImple과 list데이터를 사용해서 Page를 반환했다. 이때, 생성자에 list만 넣고 생성하면서 Unpaged객체로 생성되었고, Jackson이 직렬화하는 과정에서 getOffset메소드를 실행한 것이다. Unpaged에서 offset을 얻으려고 하면 예외를 발생시키는데 이로 인해 직렬화가 마무리되지 못하고 중단되어 에러가 발생했던 것이다.

when(drawingService.getRoomList(pageable)).thenReturn(new PageImpl<>(list));

MvcResult mvcResult = mockMvc.perform(get("/api/game/list"))
        .andExpect(status().isOk())
        .andReturn();

 

해결 방법

물론, 간단하게 해결하는 방법도 있었다. PageImpl 생성자에 pageable객체와 size만 추가하면 Paged가 되면서 offset이 예외를 발생시키지 않는다. 하지만 이 문제를 해결하기 위해, Page를 직접 반환하는 대신, 필요한 데이터만 포함하는 CustomPageDto 클래스를 만들어 사용했다.

@Getter  
public class CustomPageDto<T> {  
    private List<T> content;  
    private int number;  
    private long totalElements;  
    private int totalPages;  
    private int pageSize;  
    private boolean empty;  

    public CustomPageDto(){  
        this.content = new ArrayList<>();
        this.empty = true;  
    }

    public CustomPageDto(Page<T> page){  
        this.content = new ArrayList<>(page.getContent());  
        this.number = page.getNumber();  
        this.totalPages = page.getTotalPages();  
        this.totalElements = page.getTotalElements();  
        this.pageSize = page.getSize();  
    }  
}

 

DTO를 커스텀하여 사용하는 장점

  1. 불필요한 데이터 제거
    • Page 인터페이스에는 Pageable, Sort, Unpaged 등 직렬화할 필요가 없는 데이터가 포함되어 있다.
    • CustomPageDto<T>를 사용하면 필요한 정보만 포함하여 직렬화할 수 있다.
  2. Jackson Mapping 예외 방지
    • Page<T>를 JSON으로 변환할 때 문제가 되었던 Unpaged["offset"] 관련 예외를 해결할 수 있다.
    • 또한, 필요한 데이터만 골라서 변환하므로 예측가능한 수준으로 문제발생범위를 좁힐 수 있다.
  3. 통신 비용 절감
    • 클라우드 환경에서 REST API를 사용할 경우, 불필요한 데이터를 줄임으로써 네트워크 트래픽을 절감할 수 있다.
    • 데이터가 많아질수록 API 응답 크기가 작아지고 성능 최적화에 기여할 수 있다.

이러한 방식을 적용함으로써, WebMvcTest에서 발생한 Jackson Mapping 문제를 해결하고, 보다 효율적인 페이징 응답을 구현할 수 있었다. 3번의 경우에는 이번에 해당 문제를 해결하기 위한 다양하게 검색을 시도했는데 그때 알 수 있었던 새로운 정보이다. 최근 클라우드 사용이 많아지고 있다. 네트워크 트래픽을 감소하여 클라우드 네트워크 비용을 줄이는데 기여할 수 있다는 것을 알게 되었으며, 데이터의 크기가 줄어들면서 FE-BE통신 간 성능최적화에도 기여할 수 있다는 것이다.

결론

MockMvc로 테스트를 진행할 때, Content-Type: application/json으로 인해 응답을 Json으로 응답하게 된다. 이때, Page Interface를 그대로 응답하면 불필요한 데이터와 Page Interface 전체를 Json직렬화를 하면서 불필요한 리소스를 사용하게 된다. 이런 문제를 해결하기 위해 필요한 데이터만 골라서 전달할 수 있는 DTO를 만들고 전달하는 방법을 고려해보자.

이번 Page문제뿐만 아니라 다른 라이브러리들에도 적용될 수 있으므로 항상 응답하는 답변에 대해서 불필요한 정보가 포함되지는 않는지, 불필요한 과정이 더 많이 들어가지는 않는지 확인해 보자. 비용을 절감하는 것은 사업가나 기획, 운영을 하는 사람뿐만 아니라 개발자에게도 비즈니스에 기여해야 하는 책임이 있다. 최근 비즈니스의 경향이 오롯이 돈을 목적으로 하지는 않지만, 비즈니스와 돈은 떼려야 뗄 수 없는 관계이므로 개발자로서 1가지 방법을 알게 된데 굉장히 좋은 경험이라 생각한다.

728x90

+ Recent posts