KOSA Spring - UserDetailService

채정윤·2025년 4월 24일

Spring

목록 보기
24/25

전체 흐름 요약

  1. DB에 사용자 정보 저장
    • tbl_member: 유저의 기본 정보 (아이디, 비번, 이름 등)
    • tbl_member_auth: 유저의 권한(ROLE_*) 정보
  2. 회원정보 + 권한을 담는 VO 설계
    • MemberVO: 회원 기본 정보 + List<AuthVO>로 권한을 포함
    • AuthVO: 권한 문자열 (ROLE_USER, ROLE_ADMIN 등)
  3. CustomUser 객체 생성
    • Spring Security에서 사용하는 UserDetails 타입의 구현체
    • MemberVO를 내부에 들고 있어서, 이후 JSP나 서비스 로직에서 꺼내 사용 가능
  4. CustomUserDetailsService 구현 (UserDetailsService 인터페이스)
    • 로그인 요청 시 DB에서 userid로 사용자 조회 (MemberMapper.read)
    • 조회한 MemberVOCustomUser로 감싸 리턴
  5. security-context.xml에서 Security 설정
    • URL 별로 권한 체크
    • 로그인 성공/실패 핸들러 설정
    • UserDetailsServicecustomUserDetailService 사용하도록 설정
  6. JSP에서 로그인 사용자 정보 출력
    • <sec:authentication property="principal.member.userName"/> 등으로 유저 정보 출력 가능

🔑 핵심 개념 정리

1. UserDetailsUserDetailsService

  • Spring Security가 로그인 시 사용하는 인터페이스
  • UserDetailsServiceloadUserByUsername() 구현
  • UserDetails → 유저 정보를 담고 있는 객체 (기본 User, 커스터마이징 가능)

2. CustomUser

public class CustomUser extends User {
    private MemberVO member; // 도메인 유저 객체 포함
}
  • User의 생성자에 username, password, authorities를 전달
  • MemberVO를 내부에 포함시켜, JSP에서 사용자 상세 정보를 사용 가능

3. BCryptPasswordEncoder

  • 비밀번호 암호화용 Bean
  • DB에 암호화된 비밀번호 저장
  • 로그인 시 입력 비밀번호와 비교

4. Mapper + MyBatis resultMap

  • MemberMapper.xml에서 SQL로 사용자 + 권한 정보 조회
  • resultMap으로 MemberVO 객체에 매핑
  • collection 태그로 권한 리스트를 authList에 자동 매핑

🧩 테이블 구조와 매핑 구조

1. DB 구조 요약

tbl_membertbl_member_auth
userid (PK)userid (FK)
userpwauth (ex. ROLE_USER)
username
enabled
regdate, updatedate

➡️ 1:N 관계 (한 명의 유저가 여러 권한 가질 수 있음)


📦 MemberTests 활용

  • testInsertMember() : 더미 사용자 100명 생성
  • testInsertAuth() : 각 사용자에 권한 부여

🧪 실제 인증 시 동작 흐름

  1. 사용자가 로그인 시도
  2. UserDetailsService.loadUserByUsername() 호출
  3. DB에서 사용자 정보 + 권한 가져옴 (MemberMapper.read)
  4. CustomUser로 감싸 UserDetails 리턴
  5. Security가 이 객체로 인증 수행
  6. 로그인 성공 시 CustomLoginSuccessHandler 동작
  7. JSP에서 <sec:authentication property="..."/>로 유저 정보 사용 가능

✨ 왜 이렇게 설계했을까?

  • 실제 프로젝트에선 username, password 외에도 사용자 이름, 가입일 등 도메인 정보가 필요함
  • CustomUserMemberVOSecurity 영역과 비즈니스 영역의 연결고리를 만드는 것이 핵심
  • 이를 통해 인증 정보도 활용하고, 동시에 도메인 로직에서도 사용자 정보 일관성 있게 사용 가능

필요하다면 CustomUserDetailsService 클래스 코드도 같이 정리해줄 수 있어요!

UserDetailService 예제 코드

DB

create table tbl_member(
      userid varchar2(50) not null primary key,
      userpw varchar2(100) not null,
      username varchar2(100) not null,
      regdate date default sysdate, 
      updatedate date default sysdate,
      enabled char(1) default '1');

create table tbl_member_auth (
     userid varchar2(50) not null,
     auth varchar2(50) not null,
     constraint fk_member_auth foreign key(userid) references tbl_member(userid)
);

security-context.xml

	<bean id="customAccessDenied"
		class="org.zerock.security.CustomAccessDeniedHandler"></bean>
		
	<bean id = "customLoginSuccess"
		class="org.zerock.security.CustomLoginSuccessHandler"></bean>
		
	<bean id="bcryptPasswordEncoder"
		class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>

	<bean id="customUserDetailService"
		class="org.zerock.security.CustomUserDetailService"></bean>
	
	<security:http>
	
		<security:intercept-url pattern="/sample3/all" access="permitAll"/>
		<security:intercept-url pattern="/sample3/member" 
											access="hasRole('ROLE_MEMBER')"/>
		<security:intercept-url pattern="/sample3/admin" 
											access="hasRole('ROLE_ADMIN')"/>
											
		<security:form-login login-page="/customLogin"
									authentication-success-handler-ref="customLoginSuccess"/>
		
		<security:logout logout-url="/customLogout" invalidate-session="true"/>
		
		<!-- <security:access-denied-handler error-page="/accessError"/> -->
		<security:access-denied-handler ref="customAccessDenied"/>
	</security:http>
	
	<security:authentication-manager>
		<security:authentication-provider user-service-ref="customUserDetailService">
			
			<security:password-encoder ref="bcryptPasswordEncoder"/>
			
<!-- 				<security:user name="member" password="{noop}member" authorities="ROLE_MEMBER"/>
				<security:user name="admin" password="{noop}admin" 
											authorities="ROLE_MEMBER, ROLE_ADMIN"/> -->
			
		</security:authentication-provider>
	</security:authentication-manager>

MemberTests.java

...
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({
  "file:src/main/webapp/WEB-INF/spring/root-context.xml",
  "file:src/main/webapp/WEB-INF/spring/security-context.xml"
  })
@Log4j
public class MemberTests {

  @Setter(onMethod_ = @Autowired)
  private PasswordEncoder pwencoder;
  
  @Setter(onMethod_ = @Autowired)
  private DataSource ds;
  
  @Test
  public void testInsertMember() {

    String sql = "insert into tbl_member(userid, userpw, username) values (?,?,?)";
    
    for(int i = 0; i < 100; i++) {
      
      Connection con = null;
      PreparedStatement pstmt = null;
      
      try {
        con = ds.getConnection();
        pstmt = con.prepareStatement(sql);
        
        pstmt.setString(2, pwencoder.encode("pw" + i));
        
        if(i <80) {
          
          pstmt.setString(1, "user"+i);
          pstmt.setString(3,"일반사용자"+i);
          
        }else if (i <90) {
          
          pstmt.setString(1, "manager"+i);
          pstmt.setString(3,"운영자"+i);
          
        }else {
          
          pstmt.setString(1, "admin"+i);
          pstmt.setString(3,"관리자"+i);
          
        }
        
        pstmt.executeUpdate();
        
      }catch(Exception e) {
        e.printStackTrace();
      }finally {
        if(pstmt != null) { try { pstmt.close();  } catch(Exception e) {} }
        if(con != null) { try { con.close();  } catch(Exception e) {} }
        
      }
    }//end for
  }
  
  @Test
  public void testInsertAuth() {
    
    
    String sql = "insert into tbl_member_auth (userid, auth) values (?,?)";
    
    for(int i = 0; i < 100; i++) {
      
      Connection con = null;
      PreparedStatement pstmt = null;
      
      try {
        con = ds.getConnection();
        pstmt = con.prepareStatement(sql);
      
        
        if(i <80) {
          
          pstmt.setString(1, "user"+i);
          pstmt.setString(2,"ROLE_USER");
          
        }else if (i <90) {
          
          pstmt.setString(1, "manager"+i);
          pstmt.setString(2,"ROLE_MEMBER");
          
        }else {
          
          pstmt.setString(1, "admin"+i);
          pstmt.setString(2,"ROLE_ADMIN");
          
        }
        
        pstmt.executeUpdate();
        
      }catch(Exception e) {
        e.printStackTrace();
      }finally {
        if(pstmt != null) { try { pstmt.close();  } catch(Exception e) {} }
        if(con != null) { try { con.close();  } catch(Exception e) {} }
        
      }
    }//end for
  }
}

MemberVO.java

@Data
public class MemberVO {

	private String userid;
	private String userpw;
	private String userName;
	private boolean enabled;

	private Date regDate;
	private Date updateDate;
	private List<AuthVO> authList;
}

AuthVO.java

@Data
public class AuthVO {

  private String userid;
  private String auth;
}

CustomUser.java

...
@Getter
public class CustomUser extends User {

	private static final long serialVersionUID = 1L;

	private MemberVO member;

	public CustomUser(String username, String password, 
			Collection<? extends GrantedAuthority> authorities) {
		super(username, password, authorities);
	}

	public CustomUser(MemberVO vo) {

		super(vo.getUserid(), vo.getUserpw(), vo.getAuthList().stream()
				.map(auth -> new SimpleGrantedAuthority(auth.getAuth())).collect(Collectors.toList()));

		this.member = vo;
	}
}

✅ 왜 이렇게 설계했을까?

Spring Security에서 로그인한 사용자 정보를 저장할 때 UserDetails 타입을 사용하는데, User는 그 구현체입니다.

하지만 실제 서비스에서는 아이디, 비밀번호 외에도 추가적인 사용자 정보가 필요할 수 있기 때문에, 그 정보를 member 객체에 담아 함께 관리하는 방식입니다.

즉, CustomUserSpring Security 인증 객체와 실제 비즈니스 도메인 객체(MemberVO)를 연결해주는 역할을 합니다.

admin.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
    
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>    
<%@ taglib uri="http://www.springframework.org/security/tags" prefix="sec" %>
    
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>/sample/admin page</h1>

<p>principal : <sec:authentication property="principal"/></p>
<p>MemberVO : <sec:authentication property="principal.member"/></p>
<p>사용자이름 : <sec:authentication property="principal.member.userName"/></p>
<p>사용자아이디 : <sec:authentication property="principal.username"/></p>
<p>사용자 권한 리스트  : <sec:authentication property="principal.member.authList"/></p>

<a href="/customLogout">Logout</a>

</body>
</html>

🔩 MemberMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.zerock.mapper.MemberMapper">

  <resultMap type="org.zerock.domain.MemberVO" id="memberMap">
    <id property="userid" column="userid"/>
    <result property="userid" column="userid"/>
    <result property="userpw" column="userpw"/>
    <result property="userName" column="username"/>
    <result property="regDate" column="regdate"/>
    <result property="updateDate" column="updatedate"/>
    <collection property="authList" resultMap="authMap">
    </collection> 
  </resultMap>
  
  <resultMap type="org.zerock.domain.AuthVO" id="authMap">
    <result property="userid" column="userid"/>
    <result property="auth" column="auth"/>
  </resultMap>
  
  <select id="read" resultMap="memberMap">
		SELECT 
		  mem.userid,  userpw, username, enabled, regdate, updatedate, auth
		FROM 
		  tbl_member mem LEFT OUTER JOIN tbl_member_auth auth 
		  on mem.userid = auth.userid 
		WHERE mem.userid = #{userid} 
  </select>

</mapper>

🅱️ MemberMapper.java

package org.zerock.mapper;

import org.zerock.domain.MemberVO;

public interface MemberMapper {

	public MemberVO read(String userid);
}

0개의 댓글