Servlet_06) 웹 애플리케이션 세션 관리와 보안 강화
2025. 10. 9. 20:25

 

학습 목표

  • 세션과 쿠키의 차이점과 동작 원리를 이해한다
  • 세션 기반 인증 시스템의 로그인/로그아웃 프로세스를 구현한다
  • 접근 제어 메커니즘으로 인증과 인가를 적용한다
  • 보안 취약점을 식별하고 방어 기법을 학습한다

<세션과 쿠키의 차이점>

HTTP는 무상태 프로토콜이므로 클라이언트의 상태를 유지하기 위해 세션과 쿠키를 사용한다. 두 기술은 상태 관리라는 공통 목적을 가지지만 저장 위치와 보안 수준에서 차이가 있다.

- 쿠키의 특징

 
 
java
// 쿠키 생성 및 전송
Cookie cookie = new Cookie("userId", "user01");
cookie.setMaxAge(60 * 60 * 24);  // 24시간
cookie.setPath("/");
response.addCookie(cookie);

// 쿠키 읽기
Cookie[] cookies = request.getCookies();
if(cookies != null) {
    for(Cookie cookie : cookies) {
        String name = cookie.getName();
        String value = cookie.getValue();
    }
}

실행 결과:

 
 
클라이언트(브라우저)에 저장
최대 4KB 크기 제한
문자열 데이터만 저장 가능
클라이언트에서 조작 가능하므로 보안에 취약

- 세션의 특징

 
 
java
// 세션 생성 및 데이터 저장
HttpSession session = request.getSession();
session.setAttribute("loginMember", loginMember);
session.setMaxInactiveInterval(60 * 30);  // 30분

// 세션 데이터 읽기
Member loginMember = (Member) session.getAttribute("loginMember");

// 세션 삭제
session.removeAttribute("loginMember");  // 특정 속성만 삭제
session.invalidate();  // 세션 전체 삭제

실행 결과:

 
 
서버 메모리에 저장
저장 공간 제한 거의 없음
객체 타입도 저장 가능
클라이언트는 세션 ID만 보유하므로 안전

세션과 쿠키의 선택 기준은 데이터의 민감도다. 로그인 정보와 같은 중요 데이터는 세션으로, 테마 설정과 같은 단순 정보는 쿠키로 관리한다.


<세션 기반 로그인 시스템>

프로젝트의 LoginController는 세션을 활용하여 사용자 인증 정보를 관리한다. 로그인 성공 시 세션에 Member 객체를 저장하고 이를 통해 로그인 상태를 유지한다.

- 로그인 프로세스 구현

 
 
java
@WebServlet("/login.me")
public class LoginController extends HttpServlet {
    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        // 1. 사용자 입력 받기
        String userId = request.getParameter("userId");
        String userPwd = request.getParameter("userPwd");
        
        // 2. DB 조회
        Member loginMember = new MemberService().loginMember(userId, userPwd);
        
        if(loginMember == null) {
            // 로그인 실패
            request.setAttribute("errorMsg", "로그인에 실패하였습니다.");
            request.getRequestDispatcher("/views/common/error.jsp")
                   .forward(request, response);
        } else {
            // 로그인 성공
            HttpSession session = request.getSession();
            session.setAttribute("alertMsg", "로그인에 성공하였습니다.");    
            session.setAttribute("loginMember", loginMember);
            
            response.sendRedirect(request.getContextPath());
        }
    }
}

실행 결과:

 
 
로그인 성공: 세션에 Member 객체 저장 후 메인 페이지로 리다이렉트
로그인 실패: error.jsp로 포워드하여 에러 메시지 표시
URL은 /login.me로 유지되어 실패 상황 명확히 전달

- 로그아웃 프로세스 구현

 
 
java
@WebServlet("/logout.me")
public class LogoutController extends HttpServlet {
    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        HttpSession session = request.getSession(); 
        session.removeAttribute("loginMember");
        
        response.sendRedirect(request.getContextPath());
    }
}

실행 결과:

 
 
세션에서 loginMember 속성만 제거
다른 세션 데이터는 유지
메인 페이지로 이동하여 로그인 폼 표시

- JSP에서 로그인 상태 확인

 
 
jsp
<c:choose>
    <c:when test="${empty sessionScope.loginMember}">
        <!-- 로그인 전 -->
        <form action="${pageContext.request.contextPath}/login.me" method="post">
            <input type="text" name="userId" required>
            <input type="password" name="userPwd" required>
            <button type="submit">로그인</button>
        </form>
    </c:when>
    <c:otherwise>
        <!-- 로그인 후 -->
        <span>${loginMember.memberName}님</span>의 방문을 환영합니다.
        <a href="${pageContext.request.contextPath}/myPage.me">마이페이지</a>
        <a href="${pageContext.request.contextPath}/logout.me">로그아웃</a>
    </c:otherwise>
</c:choose>

실행 결과:

 
 
sessionScope.loginMember가 null이면 로그인 폼 표시
존재하면 회원명과 마이페이지, 로그아웃 링크 표시
JSTL의 조건문으로 동적 화면 구성

로그인 시스템의 핵심은 성공 시 세션에 사용자 정보를 저장하고, 이후 요청마다 세션 정보를 확인하여 로그인 상태를 유지하는 것이다.


<접근 제어 메커니즘>

인증된 사용자만 특정 페이지에 접근할 수 있도록 제어하는 것이 접근 제어다. MyPageController는 로그인 검증을 통해 비인증 사용자의 접근을 차단한다.

- 마이페이지 접근 제어

 
 
java
@WebServlet("/myPage.me")
public class MyPageController extends HttpServlet {
    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        HttpSession session = request.getSession();
        
        // 로그인 여부 확인
        if(session.getAttribute("loginMember") == null) {
            session.setAttribute("alertMsg", "잘못된 접근입니다.");
            response.sendRedirect(request.getContextPath());
        } else {
            request.getRequestDispatcher("views/member/myPage.jsp")
                   .forward(request, response);
        }
    }
}

실행 결과:

 
 
로그인한 사용자: myPage.jsp 접근 허용
비로그인 사용자: 메인 페이지로 리다이렉트
URL 직접 입력으로도 접근 불가

- 회원정보 수정 시 본인 확인

 
 
java
@WebServlet("/updatePwd.me")
public class UpdatePwdController extends HttpServlet {
    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        String userPwd = request.getParameter("userPwd");
        String updatePwd = request.getParameter("updatePwd");
        
        HttpSession session = request.getSession();
        Member loginMember = (Member)session.getAttribute("loginMember");
        
        // 1. 로그인 확인
        if(loginMember == null || !loginMember.getMemberPwd().equals(userPwd)) {
            request.setAttribute("errorMsg", "정상적인 접근이 아닙니다.");
            request.getRequestDispatcher("views/common/error.jsp")
                   .forward(request, response);
            return;
        }
        
        // 2. 비밀번호 변경
        Member updateMember = new MemberService()
            .updateMemberPwd(loginMember.getMemberId(), updatePwd);
        
        if(updateMember == null) {
            request.setAttribute("errorMsg", "비밀번호 수정에 실패하였습니다");
            request.getRequestDispatcher("views/common/error.jsp")
                   .forward(request, response);
        } else {
            session.setAttribute("loginMember", updateMember);
            session.setAttribute("alertMsg", "성공적으로 수정하였습니다.");
            response.sendRedirect(request.getContextPath() + "/myPage.me");
        }
    }
}

실행 결과:

 
 
로그인 확인 + 현재 비밀번호 확인으로 이중 검증
비밀번호 불일치 시 에러 페이지 표시
변경 성공 시 세션 정보 갱신 후 마이페이지로 이동

- 회원 탈퇴 시 보안 검증

 
 
java
@WebServlet("/delete.me")
public class DeleteController extends HttpServlet {
    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        String userPwd = request.getParameter("userPwd");
        HttpSession session = request.getSession();
        Member loginMember = (Member)session.getAttribute("loginMember");
        
        // 로그인 확인 + 비밀번호 확인
        if(loginMember == null || !loginMember.getMemberPwd().equals(userPwd)) {
            request.setAttribute("errorMsg", "정상적인 접근이 아닙니다.");
            request.getRequestDispatcher("views/common/error.jsp")
                   .forward(request, response);
            return;
        }
        
        int result = new MemberService().deleteMember(loginMember.getMemberId());
        
        if(result > 0) {
            session.setAttribute("alertMsg", "탈퇴가 완료되었습니다.");
            session.removeAttribute("loginMember");
            response.sendRedirect(request.getContextPath());
        } else {
            session.setAttribute("alertMsg", "탈퇴에 실패하였습니다.");
            request.getRequestDispatcher("views/common/error.jsp")
                   .forward(request, response);
        }
    }
}

실행 결과:

 
 
중요한 작업은 반드시 비밀번호 재확인
탈퇴 성공 시 세션에서 loginMember 제거
메인 페이지로 리다이렉트

접근 제어의 핵심은 로그인 여부 확인이 1차 검증이고, 본인 확인(비밀번호 재확인)이 2차 검증이다. 중요한 작업일수록 더 강력한 검증이 필요하다.


<일회성 메시지 처리>

alertMsg는 한 번만 표시되고 사라지는 일회성 메시지다. menubar.jsp에서 JSTL을 활용하여 메시지 표시 후 즉시 제거한다.

- 일회성 메시지 구현

 
 
jsp
<%-- menubar.jsp --%>
<c:if test="${not empty alertMsg}">
    <script>
        alert("${alertMsg}");
    </script>
    <c:remove var="alertMsg"/>
</c:if>

실행 결과:

 
 
alertMsg가 세션에 존재하면 알림창 표시
표시 후 즉시 세션에서 제거
새로고침 시 메시지 중복 표시 방지

- Controller에서 alertMsg 설정

 
 
java
// 로그인 성공 시
session.setAttribute("alertMsg", "로그인에 성공하였습니다.");

// 회원가입 성공 시
session.setAttribute("alertMsg", "성공적으로 회원가입을 완료하였습니다.");

// 정보수정 성공 시
session.setAttribute("alertMsg", "성공적으로 정보수정을 완료하였습니다.");

실행 결과:

 
 
성공 메시지를 세션에 저장
리다이렉트 후 menubar.jsp에서 자동 표시
한 번 표시 후 제거되어 중복 방지

일회성 메시지의 장점은 리다이렉트 후에도 메시지 전달이 가능하고, 새로고침 시 중복 표시되지 않는다는 점이다.


<에러 처리 전략>

error.jsp는 모든 에러 상황을 통합하여 처리하는 공통 에러 페이지다. errorMsg를 통해 사용자에게 명확한 에러 메시지를 전달한다.

- 에러 페이지 구현

 
 
jsp
<%-- error.jsp --%>
<div class="error-card">
    <div class="alert alert-danger text-center">
        <h4>오류 발생</h4>
        <p>
            <c:out value="${errorMsg}" default="알 수 없는 오류가 발생하였습니다." />
        </p>
        <a href="javascript:history.back()">이전 페이지로</a>
        <a href="${pageContext.request.contextPath}/">홈으로</a>
    </div>
</div>

실행 결과:

 
 
errorMsg가 있으면 해당 메시지 표시
없으면 기본 메시지 표시
이전 페이지 또는 홈으로 이동 링크 제공

- Controller에서 에러 처리

 
 
java
// 로그인 실패
if(loginMember == null) {
    request.setAttribute("errorMsg", "로그인에 실패하였습니다.");
    request.getRequestDispatcher("/views/common/error.jsp")
           .forward(request, response);
}

// 비밀번호 불일치
if(!loginMember.getMemberPwd().equals(userPwd)) {
    request.setAttribute("errorMsg", "정상적인 접근이 아닙니다.");
    request.getRequestDispatcher("views/common/error.jsp")
           .forward(request, response);
    return;
}

// 회원가입 실패
if(result <= 0) {
    request.setAttribute("errorMsg", "회원가입에 실패하였습니다.");
    request.getRequestDispatcher("/views/common/error.jsp")
           .forward(request, response);
}

실행 결과:

 
 
에러 발생 시 errorMsg를 request 영역에 저장
error.jsp로 포워드하여 에러 메시지 표시
URL은 원래 요청 URL로 유지되어 상황 파악 용이

에러 처리의 핵심은 모든 에러를 error.jsp에서 통합 처리하여 일관된 사용자 경험을 제공하는 것이다.


<SQL 인젝션 방어>

PreparedStatement를 사용하여 SQL 인젝션 공격을 방어한다. MemberDao의 모든 쿼리는 PreparedStatement로 구현되어 있다.

- 안전한 SQL 실행

 
 
java
public Member loginMember(String userId, String userPwd, Connection conn) {
    Member m = null;
    PreparedStatement pstmt = null;
    ResultSet rset = null;
    
    String sql = prop.getProperty("loginMember");
    
    try {
        pstmt = conn.prepareStatement(sql);
        pstmt.setString(1, userId);
        pstmt.setString(2, userPwd);
        
        rset = pstmt.executeQuery();
        
        if(rset.next()) {
            m = new Member(
                rset.getInt("MEMBER_NO"),
                rset.getString("MEMBER_ID"),
                rset.getString("MEMBER_PWD"),
                rset.getString("MEMBER_NAME"),
                rset.getString("PHONE"),
                rset.getString("EMAIL"),
                rset.getString("ADDRESS"),
                rset.getString("INTEREST"),
                rset.getDate("ENROLL_DATE"),
                rset.getDate("MODIFY_DATE"),
                rset.getString("STATUS")
            );
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        close(rset);
        close(pstmt);
    }
    
    return m;
}

실행 결과:

 
 
PreparedStatement가 입력값을 자동으로 이스케이프 처리
userId에 "admin' OR '1'='1" 입력해도 문자열로 인식
SQL 인젝션 공격 원천 차단

- Properties 파일로 SQL 관리

 
 
java
public class MemberDao {
    private Properties prop = new Properties();
    
    public MemberDao() {
        String path = JDBCTemplate.class
            .getResource("/db/sql/member-mapper.xml").getPath();
        
        try {
            prop.loadFromXML(new FileInputStream(path));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

실행 결과:

 
 
SQL 쿼리를 XML 파일로 분리
코드와 SQL을 분리하여 유지보수성 향상
SQL 수정 시 Java 코드 재컴파일 불필요

SQL 인젝션 방어의 핵심은 절대로 문자열 연결로 쿼리를 만들지 않고, 항상 PreparedStatement를 사용하는 것이다.


<실무 보안 체크리스트>

프로젝트 코드를 기반으로 실무에서 반드시 확인해야 할 보안 체크리스트를 정리한다.

- 인증 관련 체크리스트

 
 
java
// ✅ 체크 1: 중요 페이지는 반드시 로그인 검증
@WebServlet("/myPage.me")
public class MyPageController extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession();
        if(session.getAttribute("loginMember") == null) {
            // 비로그인 사용자 차단
            response.sendRedirect(request.getContextPath());
            return;
        }
        // 이후 로직 실행
    }
}

// ✅ 체크 2: 중요 작업은 비밀번호 재확인
@WebServlet("/updatePwd.me")
public class UpdatePwdController extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        String userPwd = request.getParameter("userPwd");
        Member loginMember = (Member)session.getAttribute("loginMember");
        
        if(!loginMember.getMemberPwd().equals(userPwd)) {
            // 비밀번호 불일치 시 차단
            return;
        }
        // 이후 로직 실행
    }
}

// ✅ 체크 3: 로그아웃 시 세션 정리
@WebServlet("/logout.me")
public class LogoutController extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession();
        session.removeAttribute("loginMember");  // 로그인 정보 제거
        response.sendRedirect(request.getContextPath());
    }
}

실행 결과:

 
 
모든 보호 페이지에서 로그인 검증 수행
민감한 작업은 비밀번호 재확인 필수
로그아웃 시 세션 정보 완전히 제거

- 데이터 접근 관련 체크리스트

 
 
java
// ✅ 체크 4: PreparedStatement 사용
public int updateMember(Member m, Connection conn) {
    PreparedStatement pstmt = null;
    String sql = prop.getProperty("updateMember");
    
    try {
        pstmt = conn.prepareStatement(sql);
        pstmt.setString(1, m.getPhone());
        pstmt.setString(2, m.getEmail());
        // 나머지 파라미터 설정
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

// ✅ 체크 5: Connection은 항상 close
public Member updateMember(Member m) {
    Connection conn = getConnection();
    int result = new MemberDao().updateMember(m, conn);
    
    if(result > 0) {
        commit(conn);
    } else {
        rollback(conn);
    }
    
    close(conn);  // 반드시 close
    return updateMember;
}

실행 결과:

 
 
모든 SQL 실행은 PreparedStatement 사용
리소스는 finally 또는 close 메서드로 반드시 정리
커넥션 누수 방지

- JSP 출력 관련 체크리스트

 
 
jsp
<%-- ✅ 체크 6: 사용자 입력은 이스케이프 처리 --%>
<p>회원명: <c:out value="${loginMember.memberName}"/></p>

<%-- ✅ 체크 7: 비밀번호는 toString에서 제외 --%>
@ToString(exclude = {"memberPwd"})
public class Member {
    private String memberPwd;
}

<%-- ✅ 체크 8: 에러 메시지도 이스케이프 --%>
<p><c:out value="${errorMsg}" default="알 수 없는 오류가 발생하였습니다." /></p>

실행 결과:

 
 
XSS 공격 방지를 위해 c:out 사용
민감 정보는 로그나 화면에 노출 금지
모든 사용자 입력은 검증 후 출력

보안 체크리스트의 핵심은 모든 입력은 검증하고, 모든 출력은 이스케이프 처리하며, 모든 중요 작업은 인증과 인가를 거치도록 하는 것이다.