제목: OAuth2 + JWT 코드에서 /api/admin/** 경로에 접근하지 못했던 이유와 해결 과정


문제 정의

Spring Boot 애플리케이션에서 OAuth2 인증과 JWT 기반의 권한 관리를 설정하면서, ROLE_ADMIN 권한을 가진 사용자가 /api/admin/** 경로에 접근할 수 없다는 문제가 발생했습니다. 이번 포스팅에서는 이 문제의 원인을 찾고, 해결하는 과정을 공유하고자 합니다.


문제 발생: /api/admin/** 경로에 접근하지 못하는 이유

OAuth2 인증 후 사용자가 ROLE_ADMIN 권한을 가지고 있음에도 불구하고, /api/admin/** 경로에 접근할 때 403 Forbidden 응답이 반환되었습니다. 다음은 이 문제를 해결하기 위한 디버깅 과정과 해결 방법에 대한 기록입니다.

1. 문제 증상

로그를 통해 문제를 확인한 결과, 사용자가 ROLE_ADMIN 권한을 가지고 있음에도 불구하고, Spring Security에서 해당 권한을 인식하지 못하고 있었습니다.

 

2024-06-23 19:55:27 [http-nio-8080-exec-10] DEBUG o.s.security.web.FilterChainProxy - Securing POST /api/admin/announcements
2024-06-23 19:55:27 [http-nio-8080-exec-9] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2024-06-23 19:55:27 [http-nio-8080-exec-9] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8080/login

로그에서 AnonymousAuthenticationFilter가 활성화되었고, SecurityContext가 익명으로 설정된 것을 확인할 수 있었습니다.

2. 문제 원인 분석

다양한 점검을 통해 다음과 같은 문제 원인들을 발견했습니다:

  • SecurityContext가 올바르게 설정되지 않음: 인증된 사용자의 정보가 SecurityContext에 제대로 설정되지 않았기 때문에, 사용자가 익명으로 처리되었습니다.
  • OAuth2 인증 후 JWT 사용: OAuth2 인증 후, 프론트엔드와의 통신은 커스텀 JWT 토큰을 사용하고 있었습니다. 이로 인해 OAuth2 인증 정보가 실제 사용되지 않았습니다.
  • JWT 필터 설정의 불완전: JWT를 기반으로 한 권한 부여 로직이 SecurityContext에 제대로 반영되지 않았습니다.

문제 해결: /api/admin/** 경로 접근 문제

문제를 해결하기 위해 다음과 같은 단계들을 거쳤습니다:

1. SecurityContext 설정 확인

먼저, OAuth2 로그인 후 SecurityContext에 사용자 인증 정보가 올바르게 설정되고 유지되는지 확인했습니다. 이를 위해 AuthSuccessHandler에서 JWT 토큰을 생성하고, 이를 기반으로 SecurityContext를 설정하도록 수정했습니다.

 

//oauth2 로그인 성공 핸들러
@Component
public class AuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final UserService userService;

    public AuthSuccessHandler(UserService userService) {
        this.userService = userService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        if (authentication instanceof OAuth2AuthenticationToken) {
            User user = userService.findUserByOAuthToken((OAuth2AuthenticationToken) authentication);

            // 로그인 성공시 사용자 정보를 기반으로 JWT 토큰 생성
            String token = userService.generateTokenForUser(user);

        }
    }
}

2. JWT 필터 설정 및 권한 부여

JWT 토큰을 사용하여 프론트엔드와의 통신을 처리하면서, Spring Security가 이를 인식하고 적절한 권한을 부여하도록 JwtTokenFilter를 수정했습니다.

 

@Component
public class JwtTokenFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserService userService;

    public JwtTokenFilter(JwtService jwtService, UserService userService) {
        this.jwtService = jwtService;
        this.userService = userService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        SecurityContext context = SecurityContextHolder.getContext();
		//스프링 시큐리티 콘텍스트에서 인증정보를 가지고 있지 않으면 인증정보 추가
        if (context.getAuthentication() == null) {
            String token = request.getHeader("Authorization");
            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7); // "Bearer " 접두어 제거

                try {
                    // 토큰 유효성 검사 및 클레임 추출
                    Claims claims = jwtService.validateTokenAndExtractClaims(token);

                    // 클레임에서 사용자 ID 추출
                    String userId = claims.getSubject();
                    
                    // 사용자 정보 조회
                    User tokenUser = userService.findUserById(userId);

                    // 사용자 엔티티에서 권한 정보 추출
                    List<GrantedAuthority> authorities = tokenUser.getRoles().stream()
                            .map(User.Role::name)
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());

                    // 새로운 Authentication 객체 생성
                    Authentication authentication = new UsernamePasswordAuthenticationToken(
                            userId, null, authorities);

                    // SecurityContext에 Authentication 객체 저장
                    context.setAuthentication(authentication);
                } catch (Exception e) {
                    // 토큰 검증 실패 처리
                    SecurityContextHolder.clearContext();
                    logger.error("JWT 토큰 검증 실패: {}", e.getMessage());
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

3. Spring Security 설정 조정

JWT 필터가 작동하도록 Spring Security 설정을 수정했습니다. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가하여, JWT가 유효한 경우 적절한 권한을 부여하도록 했습니다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(corsCustomizer()) // CORS 설정 적용
        .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용
            .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") // '/api/admin' 경로는 ROLE_ADMIN 권한을 가진 사용자만 접근 허용
            .anyRequest().permitAll() // 그 외 모든 요청은 로그인 여부에 관계없이 허용
        )
        .oauth2Login(oauth2 -> oauth2
            .successHandler(authSuccessHandler) // 로그인 성공 핸들러
            .failureUrl("/loginFailure") // 로그인 실패 시 이동할 URL
            .userInfoEndpoint(userInfo ->
                userInfo.userService(customOAuth2UserService) // 사용자 정보를 로드하는 서비스
            )
        )
        //JwtTokenFilter가 UsernamePasswordAuthenticationFilter보다 먼저실행돼야함. (addFilterBefore)
        .addFilterBefore(new JwtTokenFilter(jwtService, userService), UsernamePasswordAuthenticationFilter.class);
    
    return http.build();
}

 

결론

이번 포스팅에서는 /api/admin/** 경로에 접근하지 못했던 이유를 분석하고, 해결하는 과정을 다루었습니다. OAuth2 인증 후 JWT 토큰을 사용하여 프론트엔드와 소통하고, Spring Security에서 권한을 부여하는 방법을 통해 문제를 해결할 수 있었습니다.

  1. SecurityContext 설정 확인: OAuth2 로그인 후 SecurityContext에 인증 정보가 제대로 설정되고 유지되는지 확인했습니다.
  2. JWT 필터 설정: JWT 토큰을 검증하고 SecurityContext에 권한을 부여하는 필터를 구성했습니다.
  3. Spring Security 설정 조정: Spring Security의 필터 체인에서 JWT 필터가 적절하게 작동하도록 설정을 조정했습니다.

이러한 해결 과정을 통해 애플리케이션의 인증 및 권한 부여를 보다 견고하게 구축할 수 있었습니다. 여러분의 프로젝트에서도 비슷한 문제가 발생했을 때, 이 글이 도움이 되기를 바랍니다.

pring Boot 애플리케이션에서 API 전용으로 설정했음에도 불구하고, /favicon.ico 요청으로 인해 Circular view path [favicon] 오류가 발생하는 경우가 있습니다. 이 오류는 Spring Boot가 favicon.ico 요청을 처리하려고 시도할 때 적절한 핸들러를 찾지 못해 발생합니다.

이 포스팅에서는 이 문제를 해결하기 위해 사용한 두 가지 접근 방법에 대해 설명하겠습니다.

문제 설명

Spring Boot 애플리케이션에서 /favicon.ico에 대한 GET 요청이 들어오면, 이를 처리할 핸들러가 없을 때 Circular view path 오류가 발생할 수 있습니다. 이 오류는 기본적으로 뷰 리졸버가 요청을 처리하려고 시도하지만, 이를 반복해서 호출하게 되어 발생합니다.

jakarta.servlet.ServletException: Circular view path [favicon]: would dispatch back to the current handler URL [/favicon] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)
 

이 문제를 해결하기 위해서는 favicon.ico 요청을 적절히 처리하거나 무시하도록 설정해야 합니다.

해결 방법

문제를 해결하기 위해 다음 두 가지 방법을 사용했습니다:

  1. 정적 리소스 핸들러 설정
  2. /favicon.ico 요청을 처리하는 Controller 추가

1. 정적 리소스 핸들러 설정

Spring Boot에서는 WebMvcConfigurer를 사용하여 정적 리소스를 제공할 수 있는 경로를 설정할 수 있습니다. 정적 리소스 핸들러를 설정함으로써, /static/** 경로의 리소스 요청이 classpath:/static/ 디렉토리에서 제공되도록 할 수 있습니다.

아래는 정적 리소스 핸들러를 설정한 코드입니다:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/");
    }
}
 
 

이 설정을 통해 애플리케이션은 /static/ 경로의 정적 리소스를 classpath:/static/에서 제공하게 됩니다. 그러나 /favicon.ico 요청은 여전히 처리되지 않기 때문에, 다음 단계에서 이를 처리하는 Controller를 추가해야 합니다.

2. /favicon.ico 요청을 처리하는 Controller 추가

/favicon.ico 요청에 대해 204 No Content 응답을 반환하도록 설정하여, Spring Boot가 이 요청을 적절히 처리하도록 할 수 있습니다. 이를 위해 간단한 Controller를 추가합니다:

 
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/");
    }
}

 

이 Controller는 /favicon.ico 요청이 들어올 때 빈 응답을 반환하며, HTTP 상태 코드 204 (No Content)를 설정합니다. 이를 통해 순환 참조 오류 없이 안전하게 요청을 무시할 수 있습니다.

최종 결과

위의 두 가지 설정을 적용한 후, /favicon.ico 요청으로 인해 발생하던 Circular view path 오류는 더 이상 발생하지 않게 되었습니다. 이 과정에서 우리는 /favicon.ico 요청을 무시하거나 적절히 처리하는 방법을 배웠으며, Spring Boot 애플리케이션에서 API와 정적 리소스 경로를 명확히 구분할 수 있게 되었습니다.

요약

Spring Boot에서 /favicon.ico 요청으로 인해 발생하는 오류를 해결하기 위해 다음 두 가지 방법을 적용했습니다:

  1. 정적 리소스 핸들러 설정: WebMvcConfigurer를 사용하여 정적 리소스를 특정 경로에서 제공하도록 설정했습니다.
  2. /favicon.ico 요청을 처리하는 Controller 추가: /favicon.ico 요청이 들어왔을 때 204 No Content 응답을 반환하는 Controller를 추가했습니다.

이 해결 방법은 Spring Boot 애플리케이션에서 API 전용 설정을 유지하면서 불필요한 오류를 방지하는 데 매우 유용합니다. 앞으로 비슷한 문제가 발생할 때 이와 같은 방법을 적용해 보세요.

문제 상황

Docker Compose를 사용하여 Spring Boot 애플리케이션과 MySQL 데이터베이스를 함께 배포하려 했습니다. docker-compose up 명령을 실행하자, Spring Boot 애플리케이션이 데이터베이스 연결에 실패하며 시작하지 못하는 문제가 발생했습니다. 로그를 확인한 결과, Spring Boot 애플리케이션이 MySQL 데이터베이스가 준비되지 않은 상태에서 기동을 시도하고 있었던 것을 알게 되었습니다. 이는 데이터베이스가 준비되기 전에 애플리케이션이 시작되면서 연결을 시도해 실패한 것이 원인이었습니다.

cohttp://m.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.

...

org.springframework.jdbc.CannotGetJdbcConnectionException: Could not get JDBC Connection; nested exception is org.apache.commons.dbcp2.SQLNestedException: Cannot create PoolableConnectionFactory (Communications link failure)

...

java.net.ConnectException: Connection refused (Connection refused)
at java.base/sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:779)

 

해결 방법

이 문제를 해결하기 위해 Docker Compose의 depends_on 및 healthcheck 기능을 사용하여 데이터베이스가 완전히 준비될 때까지 Spring Boot 애플리케이션이 시작되지 않도록 설정했습니다. healthcheck를 통해 데이터베이스의 상태를 모니터링하고, 준비 상태를 확인한 후에만 Spring Boot 애플리케이션이 기동되도록 했습니다.

 

version: '3.8'

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: mypassword
      MYSQL_DATABASE: mydatabase
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypassword
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - my-network
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -p${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5

  springboot-app:
    build:
      context: ./springboot
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/mydatabase
      - SPRING_DATASOURCE_USERNAME=myuser
      - SPRING_DATASOURCE_PASSWORD=mypassword
    depends_on:
      db:
        condition: service_healthy
    networks:
      - my-network

volumes:
  db_data:
    driver: local

networks:
  my-network:
    driver: bridge
docker-compose up --build
명령을 사용했을때 발생하는 
failed to solve: failed to read dockerfile: open /var/lib/docker/tmp/buildkit-mount277479487/Dockerfile: no such file or directory  
오류는,
도커컴포즈 파일에 명시한 경로에서 도커파일을 찾지 못했을때 발생합니다.
먼저 도커파일이 해당 경로에 있는지 확인하고, 대소문자등 틀린 부분이 없나 확인해줍니다.
그래도 문제가 해결되지 않을경우,
혹시 도커파일을 새 텍스트파일 만들기 등으로 만들었다가 .txt를 지워버린 형태로 생성하지는 않았나 생각해봅니다.
해당 파일을 지우고, IDE에서 새 파일 만들기로 도커파일을 다시 만든 후에 컴포즈 up 명령을 실행해봅니다.
 

1. 파일 확장자 문제

.txt로 만들었다가 확장자를 지우는게 안되는 이유:

Windows 탐색기에서 파일을 생성할 때 파일 확장자가 숨겨진 경우가 많습니다. 이로 인해, Dockerfile을 생성할 때 .txt 확장자가 자동으로 추가될 수 있습니다. 사용자가 Dockerfile이라고 이름을 변경해도 실제 파일 이름은 Dockerfile.txt로 남아 있을 수 있습니다.

이 경우, 파일 탐색기에서는 Dockerfile로 보이지만, 내부적으로는 여전히 텍스트 파일로 인식되기 때문에 도커가 이를 Dockerfile로 인식하지 못합니다.

 

2. 파일 형식 문제

텍스트 편집기에서 파일을 저장할 때, 파일의 형식이 중요합니다. Windows의 메모장이나 다른 텍스트 편집기는 기본적으로 파일을 UTF-16 형식으로 저장할 수 있으며, 도커 빌드는 UTF-8 형식을 기대할 때 문제가 발생할 수 있습니다.

다음은 이 문제와 관련된 몇 가지 사항입니다:

  • 텍스트 인코딩: Dockerfile을 UTF-8 형식으로 저장해야 합니다. 많은 IDE는 파일을 기본적으로 UTF-8 형식으로 저장하지만, Windows 메모장 등은 그렇지 않을 수 있습니다.
  • 파일 끝에 숨겨진 문서 헤더: 메모장 등 일부 편집기는 파일 시작 부분에 숨겨진 BOM (Byte Order Mark)을 추가할 수 있습니다. 이것이 도커가 파일을 읽는 데 문제를 일으킬 수 있습니다.

스프링부트로 미니프로젝트를 진행하다가 404 오류에 봉착했습니다.

그냥 평소대로 작업했던 것 같은데 다음 엔드포인트에 도무지 접근을 할 수 없었습니다

@RestController
@RequestMapping("/users")
@CrossOrigin // 이 컨트롤러의 모든 메소드에 대해 CORS 요청 허용
public class UserController {

    // 컨트롤러 메소드...
}

 

pom.xml에 각 의존성의 버전 호환을 확인해 봤으나 문제가 없었고,

application.properties파일의 설정도 괜찮은 듯 보였습니다.

아무튼 서버가 빌드는 잘 되는데 엔드포인트에 접근할 수 없었습니다

하지만 곧 원인을 찾았습니다

바보같게도 클래스 파일을 @SpringBootApllication 어노테이션이 붙은 클래스 밖에 위치시켰던 것입니다.

 

만약 컴포넌트가 @SpringBootApplication 어노테이션이 붙은 클래스의 패키지 또는 그 하위 패키지 바깥에 위치한다면, 스프링 부트의 자동 컴포넌트 스캔 메커니즘은 그 컴포넌트를 찾지 못하게 됩니다. 이는 스프링 부트의 디자인 원칙 중 하나로, 애플리케이션의 구성요소를 체계적으로 관리하기 위해 도입된 제약사항입니다.

예를 들어, com.example.app 패키지에 @SpringBootApplication 어노테이션이 붙은 메인 애플리케이션 클래스가 있다면, 스프링 부트는 com.example.app 패키지와 그 하위 패키지에서 컴포넌트를 검색합니다. 만약 컴포넌트가 com.example 패키지나 그 상위에 위치한다면, 이 컴포넌트는 자동 스캔 대상에서 제외됩니다.


이 문제를 해결하기 위한 몇 가지 방법은 다음과 같습니다:

1. 컴포넌트를 메인 애플리케이션 클래스가 있는 패키지 또는 그 하위 패키지로 이동
가장 간단한 해결책은 컴포넌트를 메인 애플리케이션 클래스가 위치한 패키지 또는 그 하위 패키지로 옮기는 것입니다. 이렇게 하면 컴포넌트가 자동으로 스캔되어 빈으로 등록됩니다.

2. 컴포넌트 스캔 범위 확장
@ComponentScan 어노테이션을 사용하여 스프링 부트의 컴포넌트 스캔 범위를 명시적으로 확장할 수 있습니다. 이 어노테이션을 @SpringBootApplication 어노테이션이 붙은 클래스에 추가하고, basePackages 속성을 사용하여 추가로 스캔할 패키지를 지정합니다.

 

@SpringBootApplication
@ComponentScan(basePackages = {"com.example.app", "com.other.package"})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

이 문제는 데이터베이스에 해당 테이블이 없어서 발생하는 문제입니다.

application.properties파일에 다음 설정이 있는지 확인합니다.

 

# 데이터베이스 스키마 자동 생성 및 업데이트 설정
spring.jpa.hibernate.ddl-auto=update

 

개발 중이라면 update 옵션이 유용할 수 있으며, 프로덕션 환경에서는 보통 validate 또는 none을 사용합니다.

JPA를 이용하여 USER라는 엔티티로 데이터베이스 테이블 만들기에 실패했다

User라는 이름은 일부 데이터베이스 시스템에서 예약어로 사용되기 때문에 

다른 이름으로 수정하는 게 좋다

class의 이름을 바꿔도 되고,

다음처럼 Table어노테이션으로 이름을 지정해줘도 된다.

@Entity
@Table(name = "user_info")
public class User {
    // 클래스 내용
}

 

 

 

상황 :

저는 최근에 스프링과 마이바티스 구조의 어떤 프로젝트를 맡게 되었습니다.

이 프로젝트에는 온라인 접수 시스템이 있는데,

신청인들의 신상정보와 기타 옵션정보를 번갈아 가며 한번씩 저장하는 단순한 로직의 시스템이었습니다.

이 곳의 온라인 접수는 별로 인기가 없었기 때문에 그동안은 문제가 없었으나

최근에 인기인는 온라인 접수가 늘면서 문제가 발생했다고 합니다.

 

문제 :

여러 사용자가 동타임에 접수를 시도하면 꼭 접수 실패가 생기고,

아무 정보도 저장되지 않는데 접수가 성공했다고 나온다는 것이었습니다.

그 코드를 살펴보니 아래와 같은 형태였습니다.

 

//자바
public void 프로세스메서드 () {
...
try{
저장메서드();
}
catch{
	(실패페이지로이동)
}
...

(성공페이지로이동)
}

public void 저장메서드() {
  ...
	try{
    	신청자정보저장();
         
        옵션정보저장();
    }catch{
    	log(저장이 실패되었습니다)
    }
  ...
}


//마이바티스 매퍼
<insert id="신청자정보저장" parameterType="맵">
		<selectKey resultType="long" order="BEFORE" keyProperty="id">
			SELECT To_Number('0' || Max(id))+1 FROM 신청자정보
		</selectKey>

			INSERT INTO
            ...
</insert>


<insert id="옵션정보저장" parameterType="맵">
		<selectKey resultType="long" order="BEFORE" keyProperty="id">
			SELECT To_Number('0' || Max(id))+1 FROM 옵션정보저장
		</selectKey>

			INSERT INTO
            ...
</insert>

 

해결 : 

시스템의 형태를 보면, 상위메서드(프로세스)가 여러 분기를 통해 하위메서드(저장)를 실행시키는 모양이었습니다.

접수에 실패했는데 접수성공 페이지로 들어간다는게 제일 급한 문제이기 때문에 그 부분을 먼저 수정했습니다.

현재 코드에서는 당연하게도 오류페이지로 이동하지 않습니다.
위의 코드 예시에서 저장메서드() 내부에서 신청자정보저장() 또는 옵션정보저장() 실행 중 예외가 발생하면 해당 예외는 저장메서드() 내부의 catch 구문에서 잡힙니다. 이때, 예외가 로그로만 기록되고 있으며, 이 예외를 다시 상위 메서드인 프로세스메서드()로 전파하지 않고 있습니다. 따라서, 프로세스메서드()의 catch 구문에서는 이 예외를 잡을 수 없게 됩니다.
그래서 다음과 같이 수정해줍니다.

 

public void 저장메서드() {
  ...
  try {
    신청자정보저장();
    옵션정보저장();
  } catch (Exception e) { // 구체적인 예외 타입으로 변경하는 것이 좋습니다.
    log("저장이 실패되었습니다");
    throw e; // 여기서 예외를 다시 throw하여 상위 메서드로 전파
  }
  ...
}

 

그리고 이제 동시삽입 문제를 봅니다.

현재의 코드는 마이바티스에서 저장을 원하는 테이블의 max(id)를 하여 그 값을 저장 하려고 시도하고 있습니다.

저도 그렇지만 초보 개발자 분들이 DB삽입 로직을 짜실때 이러한 로직으로 작성하시는 분들이 있을 것 같습니다.

제가 첨부한 코드형태도 그렇고, 변수에 max(id)값을 먼저 조회후 거기에 +1 등을 해서 저장을 시도한다거나 하는.

하지만 당연하게도 이런 형태는 동타임 삽입에 취약할 수 밖에 없습니다 (기본키 제약조건)

저는 이 문제를 해결하고자 시퀀스를 사용한 방법으로 바꿨습니다.

 

시퀀스를 사용하는 방식이 도움이 되는 이유


1. 고유성 보장: 시퀀스는 데이터베이스에서 고유한 값을 순차적으로 생성하는 객체입니다. 이는 동시에 여러 트랜잭션이 발생하더라도 각 트랜잭션이 고유한 ID를 받게 보장하므로, ID 충돌 문제를 예방할 수 있습니다.
2. 성능 향상: selectKey나 count로 최대 ID를 찾는 방식은 테이블을 조회해야 하므로 오버헤드가 발생할 수 있습니다. 반면, 시퀀스는 메모리에서 값을 생성하기 때문에 이러한 오버헤드가 없으며, 따라서 더 빠른 성능을 제공합니다.
3. 동시성 관리: 시퀀스는 여러 사용자가 동시에 접근하더라도 각각에게 고유한 값을 제공할 수 있으므로, 동시성 문제를 효과적으로 관리할 수 있습니다.

오라클(티베로)에서 시퀀스를 만드는 방법

CREATE SEQUENCE 신청자정보_seq START WITH 1(현재 테이블에 값이 있다면,
이 값은 최대id의 값보다 크게 설정해주는 게 좋습니다)
INCREMENT BY 1;
CREATE SEQUENCE 옵션정보_seq START WITH 1 INCREMENT BY 1;

 

 

마이바티스 코드 수정

<insert id="신청자정보저장" parameterType="map">
    INSERT INTO 신청자정보
    (id, /* 다른 컬럼들 */)
    VALUES
    (신청자정보_seq.NEXTVAL, /* 다른 컬럼 값들 */)
</insert>

<insert id="옵션정보저장" parameterType="map">
    INSERT INTO 옵션정보
    (id, /* 다른 컬럼들 */)
    VALUES
    (옵션정보_seq.NEXTVAL, /* 다른 컬럼 값들 */)
</insert>

 

 

이 방식으로, 각 삽입 시 시퀀스에서 다음 값을 가져와 id로 사용함으로써 동시 삽입 시에도 고유한 id를 보장받을 수 있습니다. 이는 데이터베이스가 자동으로 고유한 값을 생성해주기 때문에, <selectKey>를 사용하여 최대 id를 찾는 방식보다 더 효율적이고 안전합니다. 시퀀스 사용은 특히 고성능이 요구되는 환경에서 동시 삽입 문제를 해결하는 데 유용합니다.

 

후기  :

현재는 이 정도 처리로도 어느정도 요청사항을 만족할 수 있었습니다. (정보저장에 대한 롤백처리도 진행하였음)

참고로 jpa를 이용하신다면 @GeneratedValue 어노테이션을 사용해 ID 생성함으로 동시 삽입에서 발생할 수 있는 ID 충돌 문제를 피할 수 있습니다.

+ Recent posts